|
34726
|
1300
|
12
|
2026-05-13T11:35:10.139108+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-13/1778 /Users/lukas/.screenpipe/data/data/2026-05-13/1778672110139_m2.jpg...
|
PhpStorm
|
faVsco.js – ValidateSendingMessage.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20891-improve-sms-text Project: faVsco.js, menu
JY-20891-improve-sms-text-relays, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Jiminny\Listeners\Users;
use Illuminate\Mail\Events\MessageSending;
use Jiminny\Models\User;
use Jiminny\Repositories\UserRepository;
use Psr\Log\LoggerInterface;
class ValidateSendingMessage
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct(
private readonly LoggerInterface $logger,
private readonly UserRepository $userRepository
) {
}
/**
* Handle the event.
*/
public function handle(MessageSending $event): bool
{
$message = $event->message;
$recipients = $message->getTo();
$filteredRecipients = [];
foreach ($recipients as $recipient) {
$address = $recipient->getAddress();
$user = $this->userRepository->findByEmail($address);
// Only include active users in the filtered recipients list
if (! $user || $user->status === User::STATUS_ACTIVE) {
$filteredRecipients[] = $recipient;
} else {
$this->logger->info('[Mailer] Removing inactive user from recipients: ' . $address);
}
}
// If there are no recipients left after filtering, don't send the email
if (empty($filteredRecipients)) {
$this->logger->info('[Mailer] No active recipients left, cancelling email');
return false;
}
// Set the filtered recipients back to the message
$message->to(...$filteredRecipients);
return true;
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
40
1
40
65
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993
SELECT * FROM users WHERE id = 25061;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 994;
SELECT * FROM crm_profiles WHERE user_id = 25061;
select * from crm_configurations where id = 834;
SELECT * FROM teams WHERE id = 882;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;
SELECT * FROM contacts where crm_configuration_id = 834;
SELECT * FROM opportunities WHERE team_id = 933
# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');
AND id IN (8482561,18352941,19042734,19232139,19445140,19472541);
SELECT * FROM opportunity_contacts
WHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 485; #
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
select crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id
where crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')
# and l.converted_at IS NOT NULL
;
# [PASSWORD_DOTS]
SELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')
and opportunity_id IS NULL
order by id desc;
SELECT * FROM teams WHERE id = 604; # 598
SELECT * FROM activities WHERE id = 74410828; # [EMAIL]
SELECT * FROM accounts WHERE id = 20068382;
SELECT * FROM accounts WHERE id = 35186038;
SELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 559 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;
select * from sidekick_settings where team_id = 781;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100
SELECT * FROM crm_layouts WHERE crm_configuration_id = 711;
SELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL
and is_internal = 0 and status = 'completed'
order by id desc;
SELECT * FROM crm_layout_entities
WHERE crm_layout_id IN (2352, 2353);
;
SELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 556 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;
SELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;
select * from contacts
where crm_configuration_id = 530
and crm_provider_id = 872252;
select * from activities where crm_configuration_id = 530
and user_id = 14343 and type like '%softphone%'
and created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);
SELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t
JOIN crm_configurations c ON t.id = c.team_id
WHERE t.status = 'active';
SELECT * FROM teams where id = 1091;
SELECT * FROM crm_configurations where team_id = 1091;
SELECT * FROM activity_providers where team_id = 1091;
SELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT * FROM teams WHERE name LIKE '%Leadventure%';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1091 and sa.provider = 'salesforce';
SELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812
SELECT * FROM teams where id = 862;
SELECT * FROM crm_configurations where team_id = 862;
SELECT * FROM activity_providers where team_id = 862;
SELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT t.id, crm.id, crm.provider, ap.* FROM teams t
join crm_configurations crm on t.id = crm.team_id
join activity_providers ap on t.id = ap.team_id
where t.status = 'active' and ap.is_enabled = 1
and crm.provider = 'hubspot'
and ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',
'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');
SELECT * FROM teams where id = 1068;
SELECT * FROM crm_configurations where team_id = 1068;
SELECT * FROM activity_providers where team_id = 1068;
SELECT * FROM activities a
where crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')
and a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'
)
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by a.id desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1068 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262
SELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
select * from crm_layouts where crm_configuration_id = 834;
select * from crm_layout_entities where crm_layout_id = 2780;
select * from crm_fields where id IN (321153,321192,321193,321194);
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1057 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8
SELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20
SELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10
SELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #
SELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;
select * from users where team_id = 51; # 7783
SELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130
select * from activity_searches where user_id = 7783;
select * from activity_search_filters where activity_search_id IN (32291, 32292);
SELECT asf.activity_search_id, asf.id, asf.value
FROM activity_search_filters asf
WHERE asf.filter = 'group_id'
AND asf.value IN (
SELECT CONCAT(
HEX(SUBSTR(uuid, 5, 4)), '-',
HEX(SUBSTR(uuid, 3, 2)), '-',
HEX(SUBSTR(uuid, 1, 2)), '-',
HEX(SUBSTR(uuid, 9, 2)), '-',
HEX(SUBSTR(uuid, 11))
)
FROM groups
WHERE deleted_at IS NOT NULL
);
SELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where provider = 'hubspot';
SELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133
SELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null
# [PASSWORD_DOTS]
select * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';
select
cp.*
# DISTINCT t.id
# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields
FROM crm_profiles cp
JOIN crm_configurations crm on crm.id = cp.crm_configuration_id
JOIN users u on u.id = cp.user_id
JOIN teams t ON t.id = crm.team_id
WHERE crm.provider = 'salesforce' and t.status = 'active'
and cp.archived_at IS NULL and u.deleted_at IS NULL
and t.id NOT IN (1093)
and t.id = 2
and cp.contact_fields IS NULL;
# and c.crm_provider_id = '003Uu00000ojD4NIAU';
SELECT * FROM users WHERE id = 26484;
SELECT * FROM crm_profiles WHERE user_id = 26484;
SELECT * FROM social_accounts WHERE sociable_id = 26484;
SELECT * FROM crm_configurations where provider = 'salesforce';
select * from users where id IN (10022, 10403);
select * from users where team_id IN (526);
select * from teams where id IN (526, 532);
select * from crm_configurations where id IN (500, 516);
select * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);
select * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 526 and sa.provider = 'salesforce';
select * from team_settings where team_id IN (526, 532);
select * from users where id IN (22824);
select * from crm_profiles where crm_configuration_id IN (1026);
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1093 and sa.provider = 'salesforce';
select * from teams where id = 1099;
select * from users where id = 29643
select * from activity_processing_states;
SELECT * FROM teams where name LIKE '%Fare%'; # 233
SELECT * FROM opportunities where crm_configuration_id = 215
# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'
;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1088 and sa.provider = 'hubspot';
SELECT * FROM teams order by updated_at DESC
SELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account
select * from crm_configurations where provider = 'pipedrive';
select * from teams where id = 957;
select * from crm_configurations where id = 957;
SELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743
SELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;
select * from users where team_id = 1; # 26726 - Gabriela Dureva
SELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific
select * from activities where user_id = 26726 order by id desc;
select * from contacts where crm_configuration_id = 1
and email IN ('[EMAIL]', '[EMAIL]'); # 2094416, 2093620
SELECT * FROM contacts WHERE id = 6284931;
SELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id
WHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;
select * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);
select * from crm_configurations where id = 1;
43801692-1aeb-32ce-acba-5b80a479701a
44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b
405975c0-b3d0-7aaa-821f-09d59cae6dd1
4caf848d-4bed-2299-b248-7788d41f9fca
49bedc3f-f196-eef3-89c3-dea6a3b4aa63
43420989-a09d-b8f8-9806-c8bbf7a02aac
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
SELECT * FROM activities WHERE id = 75461988;
SELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;
select * from contacts where id = 17900517;
select * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id
where crm.provider != 'salesforce';
select * from users where id = 21047;
SELECT * FROM crm_configurations WHERE id = 892;
SELECT * FROM teams WHERE id = 942;
select * from opportunities where team_id = 942 order by updated_at desc;
select * from contacts where team_id = 942 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 942 and sa.provider = 'hubspot';
SELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430
SELECT * FROM crm_configurations WHERE id = 1;
SELECT * FROM teams WHERE crm_id = 1;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
select id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1
SELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430
select * from teams where id = 852;
select * from groups where id = 2286;
select * from sidekick_settings where team_id = 852;
select * from default_activity_types where team_id = 852;
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1 AND u.deleted_at IS NULL
AND u.crm_required = 1
AND u.team_id = 1
ORDER BY u.team_id;
SELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (
18481
);
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1
AND u.deleted_at IS NULL
AND u.crm_required = 1
# AND u.team_id = 1
AND p.id IS NULL -- Move this condition to WHERE clause
ORDER BY u.team_id;
SELECT * FROM opportunities WHERE id = 20002609;
select * from teams where id = 1122; # Velatir, 29953 - [EMAIL]
select * from crm_configurations where id = 1060;
select * from crm_layouts where crm_configuration_id = 1060;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1122 and sa.provider = 'hubspot';
select * from opportunities where team_id = 1122 order by updated_at desc;
select * from crm_field_data where object_type = 'contact';
SELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 248 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS
SELECT * FROM users where id = 24115;
SELECT * FROM accounts where id = 4002896;
SELECT * FROM teams WHERE name LIKE '%adswerve%';
SELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN ("0069N000003GIQ9QAO","0061r000019yGP9AAM","0066900001S2KWlAAN","0066900001TDpj2AAD","0066900001b8uEwAAI","0069N000001rQi0QAE","006QF00000KD40mYAD","006QF00000LzpRJYAZ","0069N000002uomtQAA","0069N000002xlMLQAY","0066900001NV6ubAAD","0061r00001HJp45AAD","006QF00000uTlUoYAK","006QF00000v0bZqYAI");
SELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203
SELECT u.id, u.email, ac.name, a.* FROM activities a
JOIN users u ON a.user_id = u.id
JOIN accounts ac ON a.account_id = ac.id
WHERE
uuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or
uuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or
uuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;
select * from users where id = 5825;
SELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;
select * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;
19594, 862
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 862 and sa.provider = 'salesforce';
select * from automated_reports where id = 36;
select ar.frequency, r.*, ar.* from automated_report_results r
join automated_reports ar on r.report_id = ar.id
where ar.frequency != 'one_off';
select s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;
select * from nudges n where n.activity_search_id
select * from teams where created_at > '2026-03-09';
SELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;
select * from users where team_id = 1 and name like '%Lukas%'; # 7160
SELECT * FROM teams WHERE id = 575;
select * from opportunities where team_id = 575;
SELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,
select * from opportunities where team_id = 1126;
SELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,
select * from opportunities where team_id = 1125;
select * from contacts c
where c.team_id = 882;
SELECT * FROM activities WHERE id = 76822967;
SELECT * FROM crm_profiles WHERE user_id = 15440;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 555;
SELECT * FROM crm_configurations WHERE id = 555;
SELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 581 and sa.provider = 'salesforce';
SELECT * FROM automated_report_results order by id desc;
select * from features;
select * from team_features where feature_id = 40;
select * from teams where id = 556;
select * from automated_reports;
where id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , ["pdf","podcast"]
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from automated_report_results order by id desc;
SELECT * FROM automated_report_results WHERE id = 1919;
select * from automated_report_results WHERE report_id = 54;
select * from opportunities where id = 7594349;
SELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - [EMAIL]
select * from playbooks where team_id = 711; # event 226147
SELECT * FROM playbook_categories WHERE playbook_id = 5515;
SELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';
SELECT * FROM crm_fields WHERE id = 226147;
SELECT * FROM crm_field_values WHERE crm_field_id = 226147;
SELECT * FROM crm_configurations WHERE id = 692;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 711 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;
select * from leads;
select * from calendars;
SELECT
t.id AS team_id,
t.name,
LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain
FROM teams t
JOIN users u ON u.team_id = t.id
JOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'
LEFT JOIN team_domains td
ON td.team_id = t.id
AND td.deleted_at IS NULL
AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))
GROUP BY t.id, t.name, calendar_domain
ORDER BY t.name, calendar_domain;
select * from users u join calendars c on c.user_id = u.id
where u.team_id = 882;
select * from activities where id = 74049485; # team 563 crm 537
select * from activities where id = 73272382; # team 563 crm 537
select * from activities where id = 64400389; # team 563 crm 537
select * from activities where id = 58081273; # team 563 crm 537
select * from activities where id = 54520297; # team 563 crm 537
select * from participants where activity_id = 58081273;
select * from activities where crm_configuration_id = 537 and provider = 'aircall'
and account_id = 19003658 order by updated_at desc;
select * from contacts where crm_configuration_id = 537 and id = 35957759;
select * from accounts where crm_configuration_id = 537 and id = 19003658;
select * from automated_report_results where id = 1976;
select * from automated_reports where id = 583;
select * from activity_searches where id = 87714;
select * from activity_search_filters where activity_search_id = 87714;
SELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid
or uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;
SELECT * FROM crm_configurations WHERE provider = 'hubspot';
select * from rate_limits;
select * from automated_report_results where media_type = 'pdf' and status = 2
and id IN (18, 1872);
select * from automated_reports where id = 54;
SELECT * FROM users WHERE id IN (24623,29443,29613);
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from text_relays where created_at > '2026-05-01'
# and id IN (32415, 32416);
and id = 32412;
select * from users where team_id = 2 and email like '%scott%' and id = 29510;
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20891-improve-sms-text-relays, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.08843085,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20891-improve-sms-text-relays","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Users;\n\nuse Illuminate\\Mail\\Events\\MessageSending;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\UserRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass ValidateSendingMessage\n{\n /**\n * Create the event listener.\n *\n * @return void\n */\n public function __construct(\n private readonly LoggerInterface $logger,\n private readonly UserRepository $userRepository\n ) {\n }\n\n /**\n * Handle the event.\n */\n public function handle(MessageSending $event): bool\n {\n $message = $event->message;\n $recipients = $message->getTo();\n $filteredRecipients = [];\n\n foreach ($recipients as $recipient) {\n $address = $recipient->getAddress();\n $user = $this->userRepository->findByEmail($address);\n\n // Only include active users in the filtered recipients list\n if (! $user || $user->status === User::STATUS_ACTIVE) {\n $filteredRecipients[] = $recipient;\n } else {\n $this->logger->info('[Mailer] Removing inactive user from recipients: ' . $address);\n }\n }\n\n // If there are no recipients left after filtering, don't send the email\n if (empty($filteredRecipients)) {\n $this->logger->info('[Mailer] No active recipients left, cancelling email');\n\n return false;\n }\n\n // Set the filtered recipients back to the message\n $message->to(...$filteredRecipients);\n\n return true;\n }\n}","depth":4,"bounds":{"left":0.11968085,"top":0.14684756,"width":0.29587767,"height":0.8324022},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Users;\n\nuse Illuminate\\Mail\\Events\\MessageSending;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\UserRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass ValidateSendingMessage\n{\n /**\n * Create the event listener.\n *\n * @return void\n */\n public function __construct(\n private readonly LoggerInterface $logger,\n private readonly UserRepository $userRepository\n ) {\n }\n\n /**\n * Handle the event.\n */\n public function handle(MessageSending $event): bool\n {\n $message = $event->message;\n $recipients = $message->getTo();\n $filteredRecipients = [];\n\n foreach ($recipients as $recipient) {\n $address = $recipient->getAddress();\n $user = $this->userRepository->findByEmail($address);\n\n // Only include active users in the filtered recipients list\n if (! $user || $user->status === User::STATUS_ACTIVE) {\n $filteredRecipients[] = $recipient;\n } else {\n $this->logger->info('[Mailer] Removing inactive user from recipients: ' . $address);\n }\n }\n\n // If there are no recipients left after filtering, don't send the email\n if (empty($filteredRecipients)) {\n $this->logger->info('[Mailer] No active recipients left, cancelling email');\n\n return false;\n }\n\n // Set the filtered recipients back to the message\n $message->to(...$filteredRecipients);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.40957448,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.41821808,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"bounds":{"left":0.42918882,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"bounds":{"left":0.43783244,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"bounds":{"left":0.44647607,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"bounds":{"left":0.4574468,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"bounds":{"left":0.46841756,"top":0.09896249,"width":0.024268618,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"bounds":{"left":0.4950133,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"bounds":{"left":0.50598407,"top":0.09896249,"width":0.029587766,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.7084442,"top":0.09896249,"width":0.02825798,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"40","depth":4,"bounds":{"left":0.67785907,"top":0.123703115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.69015956,"top":0.123703115,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"40","depth":4,"bounds":{"left":0.6994681,"top":0.123703115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"65","depth":4,"bounds":{"left":0.7117686,"top":0.123703115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.7237367,"top":0.12210695,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.73105055,"top":0.12210695,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993\nSELECT * FROM users WHERE id = 25061;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 994;\nSELECT * FROM crm_profiles WHERE user_id = 25061;\n\nselect * from crm_configurations where id = 834;\nSELECT * FROM teams WHERE id = 882;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;\n\nSELECT * FROM contacts where crm_configuration_id = 834;\nSELECT * FROM opportunities WHERE team_id = 933\n# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');\nAND id IN (8482561,18352941,19042734,19232139,19445140,19472541);\nSELECT * FROM opportunity_contacts\nWHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; #\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nselect crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id\nwhere crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')\n# and l.converted_at IS NOT NULL\n;\n\n# ********************************************************************\nSELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')\nand opportunity_id IS NULL\norder by id desc;\n\nSELECT * FROM teams WHERE id = 604; # 598\nSELECT * FROM activities WHERE id = 74410828; # chelseaw@allvoices.co\nSELECT * FROM accounts WHERE id = 20068382;\nSELECT * FROM accounts WHERE id = 35186038;\n\nSELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 559 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;\nselect * from sidekick_settings where team_id = 781;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 711;\nSELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL\nand is_internal = 0 and status = 'completed'\norder by id desc;\n\nSELECT * FROM crm_layout_entities\nWHERE crm_layout_id IN (2352, 2353);\n;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;\nSELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;\nselect * from contacts\nwhere crm_configuration_id = 530\nand crm_provider_id = 872252;\n\nselect * from activities where crm_configuration_id = 530\nand user_id = 14343 and type like '%softphone%'\nand created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);\n\n\nSELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t\nJOIN crm_configurations c ON t.id = c.team_id\nWHERE t.status = 'active';\n\nSELECT * FROM teams where id = 1091;\nSELECT * FROM crm_configurations where team_id = 1091;\nSELECT * FROM activity_providers where team_id = 1091;\nSELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT * FROM teams WHERE name LIKE '%Leadventure%';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1091 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812\nSELECT * FROM teams where id = 862;\nSELECT * FROM crm_configurations where team_id = 862;\nSELECT * FROM activity_providers where team_id = 862;\nSELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT t.id, crm.id, crm.provider, ap.* FROM teams t\njoin crm_configurations crm on t.id = crm.team_id\njoin activity_providers ap on t.id = ap.team_id\nwhere t.status = 'active' and ap.is_enabled = 1\nand crm.provider = 'hubspot'\nand ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',\n 'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');\n\nSELECT * FROM teams where id = 1068;\nSELECT * FROM crm_configurations where team_id = 1068;\nSELECT * FROM activity_providers where team_id = 1068;\n\nSELECT * FROM activities a\nwhere crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')\nand a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'\n )\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by a.id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1068 and sa.provider = 'hubspot';\n\n# ********************************************************************\n# ********************************************************************\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262\nSELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\nselect * from crm_layouts where crm_configuration_id = 834;\nselect * from crm_layout_entities where crm_layout_id = 2780;\nselect * from crm_fields where id IN (321153,321192,321193,321194);\n\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1057 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8\n\nSELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20\n\nSELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10\n\nSELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #\n\nSELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;\nselect * from users where team_id = 51; # 7783\nSELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130\nselect * from activity_searches where user_id = 7783;\nselect * from activity_search_filters where activity_search_id IN (32291, 32292);\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th\n# ********************************************************************\nSELECT * FROM crm_configurations where provider = 'hubspot';\nSELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133\nSELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null\n# ********************************************************************\n\nselect * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';\nselect\n cp.*\n# DISTINCT t.id\n# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields\nFROM crm_profiles cp\nJOIN crm_configurations crm on crm.id = cp.crm_configuration_id\nJOIN users u on u.id = cp.user_id\nJOIN teams t ON t.id = crm.team_id\nWHERE crm.provider = 'salesforce' and t.status = 'active'\n and cp.archived_at IS NULL and u.deleted_at IS NULL\n and t.id NOT IN (1093)\n and t.id = 2\n and cp.contact_fields IS NULL;\n# and c.crm_provider_id = '003Uu00000ojD4NIAU';\n\nSELECT * FROM users WHERE id = 26484;\nSELECT * FROM crm_profiles WHERE user_id = 26484;\nSELECT * FROM social_accounts WHERE sociable_id = 26484;\nSELECT * FROM crm_configurations where provider = 'salesforce';\nselect * from users where id IN (10022, 10403);\nselect * from users where team_id IN (526);\nselect * from teams where id IN (526, 532);\nselect * from crm_configurations where id IN (500, 516);\nselect * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);\nselect * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 526 and sa.provider = 'salesforce';\nselect * from team_settings where team_id IN (526, 532);\n\nselect * from users where id IN (22824);\nselect * from crm_profiles where crm_configuration_id IN (1026);\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1093 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1099;\nselect * from users where id = 29643\n\nselect * from activity_processing_states;\n\nSELECT * FROM teams where name LIKE '%Fare%'; # 233\nSELECT * FROM opportunities where crm_configuration_id = 215\n# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'\n;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1088 and sa.provider = 'hubspot';\n\nSELECT * FROM teams order by updated_at DESC\nSELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account\n\nselect * from crm_configurations where provider = 'pipedrive';\n\nselect * from teams where id = 957;\nselect * from crm_configurations where id = 957;\n\nSELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743\nSELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;\n\nselect * from users where team_id = 1; # 26726 - Gabriela Dureva\nSELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific\nselect * from activities where user_id = 26726 order by id desc;\nselect * from contacts where crm_configuration_id = 1\nand email IN ('charlotte.ward@prolific.com', 'frankie.bryant@prolific.com'); # 2094416, 2093620\nSELECT * FROM contacts WHERE id = 6284931;\n\nSELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id\nWHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;\n\nselect * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);\nselect * from crm_configurations where id = 1;\n\n43801692-1aeb-32ce-acba-5b80a479701a\n44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b\n405975c0-b3d0-7aaa-821f-09d59cae6dd1\n4caf848d-4bed-2299-b248-7788d41f9fca\n49bedc3f-f196-eef3-89c3-dea6a3b4aa63\n43420989-a09d-b8f8-9806-c8bbf7a02aac\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nSELECT * FROM activities WHERE id = 75461988;\n\nSELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;\n\nselect * from contacts where id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nselect * from users where id = 21047;\nSELECT * FROM crm_configurations WHERE id = 892;\nSELECT * FROM teams WHERE id = 942;\nselect * from opportunities where team_id = 942 order by updated_at desc;\nselect * from contacts where team_id = 942 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 942 and sa.provider = 'hubspot';\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430\nSELECT * FROM crm_configurations WHERE id = 1;\nSELECT * FROM teams WHERE crm_id = 1;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1\nSELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430\n\nselect * from teams where id = 852;\nselect * from groups where id = 2286;\nselect * from sidekick_settings where team_id = 852;\nselect * from default_activity_types where team_id = 852;\n\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1 AND u.deleted_at IS NULL\nAND u.crm_required = 1\nAND u.team_id = 1\nORDER BY u.team_id;\n\nSELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (\n18481\n );\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1\n AND u.deleted_at IS NULL\n AND u.crm_required = 1\n# AND u.team_id = 1\n AND p.id IS NULL -- Move this condition to WHERE clause\nORDER BY u.team_id;\n\nSELECT * FROM opportunities WHERE id = 20002609;\nselect * from teams where id = 1122; # Velatir, 29953 - christian@velatir.com\nselect * from crm_configurations where id = 1060;\nselect * from crm_layouts where crm_configuration_id = 1060;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1122 and sa.provider = 'hubspot';\nselect * from opportunities where team_id = 1122 order by updated_at desc;\n\nselect * from crm_field_data where object_type = 'contact';\n\nSELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 248 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS\nSELECT * FROM users where id = 24115;\nSELECT * FROM accounts where id = 4002896;\nSELECT * FROM teams WHERE name LIKE '%adswerve%';\nSELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN (\"0069N000003GIQ9QAO\",\"0061r000019yGP9AAM\",\"0066900001S2KWlAAN\",\"0066900001TDpj2AAD\",\"0066900001b8uEwAAI\",\"0069N000001rQi0QAE\",\"006QF00000KD40mYAD\",\"006QF00000LzpRJYAZ\",\"0069N000002uomtQAA\",\"0069N000002xlMLQAY\",\"0066900001NV6ubAAD\",\"0061r00001HJp45AAD\",\"006QF00000uTlUoYAK\",\"006QF00000v0bZqYAI\");\nSELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203\n\nSELECT u.id, u.email, ac.name, a.* FROM activities a\nJOIN users u ON a.user_id = u.id\nJOIN accounts ac ON a.account_id = ac.id\nWHERE\nuuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or\nuuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or\nuuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;\n\nselect * from users where id = 5825;\nSELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;\n\nselect * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;\n19594, 862\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 862 and sa.provider = 'salesforce';\n\nselect * from automated_reports where id = 36;\nselect ar.frequency, r.*, ar.* from automated_report_results r\njoin automated_reports ar on r.report_id = ar.id\nwhere ar.frequency != 'one_off';\n\nselect s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;\nselect * from nudges n where n.activity_search_id\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;\n\nselect * from users where team_id = 1 and name like '%Lukas%'; # 7160\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\nSELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,\nselect * from opportunities where team_id = 1126;\nSELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,\nselect * from opportunities where team_id = 1125;\nselect * from contacts c\nwhere c.team_id = 882;\n\nSELECT * FROM activities WHERE id = 76822967;\nSELECT * FROM crm_profiles WHERE user_id = 15440;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 555;\nSELECT * FROM crm_configurations WHERE id = 555;\nSELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 581 and sa.provider = 'salesforce';\n\nSELECT * FROM automated_report_results order by id desc;\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556;\n\nselect * from automated_reports;\nwhere id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , [\"pdf\",\"podcast\"]\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\nselect * from automated_report_results order by id desc;\nSELECT * FROM automated_report_results WHERE id = 1919;\n\nselect * from automated_report_results WHERE report_id = 54;\n\nselect * from opportunities where id = 7594349;\n\nSELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - jiminnyintegration@lesmills.com\nselect * from playbooks where team_id = 711; # event 226147\nSELECT * FROM playbook_categories WHERE playbook_id = 5515;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';\nSELECT * FROM crm_fields WHERE id = 226147;\nSELECT * FROM crm_field_values WHERE crm_field_id = 226147;\n\nSELECT * FROM crm_configurations WHERE id = 692;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 711 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;\n\nselect * from leads;\n\nselect * from calendars;\n\nSELECT\n t.id AS team_id,\n t.name,\n LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain\nFROM teams t\nJOIN users u ON u.team_id = t.id\nJOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'\nLEFT JOIN team_domains td\n ON td.team_id = t.id\n AND td.deleted_at IS NULL\n AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))\nGROUP BY t.id, t.name, calendar_domain\nORDER BY t.name, calendar_domain;\n\nselect * from users u join calendars c on c.user_id = u.id\nwhere u.team_id = 882;\n\n\nselect * from activities where id = 74049485; # team 563 crm 537\nselect * from activities where id = 73272382; # team 563 crm 537\nselect * from activities where id = 64400389; # team 563 crm 537\nselect * from activities where id = 58081273; # team 563 crm 537\nselect * from activities where id = 54520297; # team 563 crm 537\nselect * from participants where activity_id = 58081273;\n\nselect * from activities where crm_configuration_id = 537 and provider = 'aircall'\nand account_id = 19003658 order by updated_at desc;\n\nselect * from contacts where crm_configuration_id = 537 and id = 35957759;\nselect * from accounts where crm_configuration_id = 537 and id = 19003658;\n\nselect * from automated_report_results where id = 1976;\nselect * from automated_reports where id = 583;\nselect * from activity_searches where id = 87714;\nselect * from activity_search_filters where activity_search_id = 87714;\n\nSELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid\nor uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot';\nselect * from rate_limits;\n\nselect * from automated_report_results where media_type = 'pdf' and status = 2\nand id IN (18, 1872);\nselect * from automated_reports where id = 54;\nSELECT * FROM users WHERE id IN (24623,29443,29613);\n\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\n\nselect * from text_relays where created_at > '2026-05-01'\n# and id IN (32415, 32416);\nand id = 32412;\n\nselect * from users where team_id = 2 and email like '%scott%' and id = 29510;","depth":4,"on_screen":true,"value":"SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993\nSELECT * FROM users WHERE id = 25061;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 994;\nSELECT * FROM crm_profiles WHERE user_id = 25061;\n\nselect * from crm_configurations where id = 834;\nSELECT * FROM teams WHERE id = 882;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;\n\nSELECT * FROM contacts where crm_configuration_id = 834;\nSELECT * FROM opportunities WHERE team_id = 933\n# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');\nAND id IN (8482561,18352941,19042734,19232139,19445140,19472541);\nSELECT * FROM opportunity_contacts\nWHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; #\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nselect crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id\nwhere crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')\n# and l.converted_at IS NOT NULL\n;\n\n# ********************************************************************\nSELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')\nand opportunity_id IS NULL\norder by id desc;\n\nSELECT * FROM teams WHERE id = 604; # 598\nSELECT * FROM activities WHERE id = 74410828; # chelseaw@allvoices.co\nSELECT * FROM accounts WHERE id = 20068382;\nSELECT * FROM accounts WHERE id = 35186038;\n\nSELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 559 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;\nselect * from sidekick_settings where team_id = 781;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 711;\nSELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL\nand is_internal = 0 and status = 'completed'\norder by id desc;\n\nSELECT * FROM crm_layout_entities\nWHERE crm_layout_id IN (2352, 2353);\n;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;\nSELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;\nselect * from contacts\nwhere crm_configuration_id = 530\nand crm_provider_id = 872252;\n\nselect * from activities where crm_configuration_id = 530\nand user_id = 14343 and type like '%softphone%'\nand created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);\n\n\nSELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t\nJOIN crm_configurations c ON t.id = c.team_id\nWHERE t.status = 'active';\n\nSELECT * FROM teams where id = 1091;\nSELECT * FROM crm_configurations where team_id = 1091;\nSELECT * FROM activity_providers where team_id = 1091;\nSELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT * FROM teams WHERE name LIKE '%Leadventure%';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1091 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812\nSELECT * FROM teams where id = 862;\nSELECT * FROM crm_configurations where team_id = 862;\nSELECT * FROM activity_providers where team_id = 862;\nSELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT t.id, crm.id, crm.provider, ap.* FROM teams t\njoin crm_configurations crm on t.id = crm.team_id\njoin activity_providers ap on t.id = ap.team_id\nwhere t.status = 'active' and ap.is_enabled = 1\nand crm.provider = 'hubspot'\nand ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',\n 'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');\n\nSELECT * FROM teams where id = 1068;\nSELECT * FROM crm_configurations where team_id = 1068;\nSELECT * FROM activity_providers where team_id = 1068;\n\nSELECT * FROM activities a\nwhere crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')\nand a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'\n )\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by a.id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1068 and sa.provider = 'hubspot';\n\n# ********************************************************************\n# ********************************************************************\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262\nSELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\nselect * from crm_layouts where crm_configuration_id = 834;\nselect * from crm_layout_entities where crm_layout_id = 2780;\nselect * from crm_fields where id IN (321153,321192,321193,321194);\n\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1057 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8\n\nSELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20\n\nSELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10\n\nSELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #\n\nSELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;\nselect * from users where team_id = 51; # 7783\nSELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130\nselect * from activity_searches where user_id = 7783;\nselect * from activity_search_filters where activity_search_id IN (32291, 32292);\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th\n# ********************************************************************\nSELECT * FROM crm_configurations where provider = 'hubspot';\nSELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133\nSELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null\n# ********************************************************************\n\nselect * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';\nselect\n cp.*\n# DISTINCT t.id\n# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields\nFROM crm_profiles cp\nJOIN crm_configurations crm on crm.id = cp.crm_configuration_id\nJOIN users u on u.id = cp.user_id\nJOIN teams t ON t.id = crm.team_id\nWHERE crm.provider = 'salesforce' and t.status = 'active'\n and cp.archived_at IS NULL and u.deleted_at IS NULL\n and t.id NOT IN (1093)\n and t.id = 2\n and cp.contact_fields IS NULL;\n# and c.crm_provider_id = '003Uu00000ojD4NIAU';\n\nSELECT * FROM users WHERE id = 26484;\nSELECT * FROM crm_profiles WHERE user_id = 26484;\nSELECT * FROM social_accounts WHERE sociable_id = 26484;\nSELECT * FROM crm_configurations where provider = 'salesforce';\nselect * from users where id IN (10022, 10403);\nselect * from users where team_id IN (526);\nselect * from teams where id IN (526, 532);\nselect * from crm_configurations where id IN (500, 516);\nselect * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);\nselect * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 526 and sa.provider = 'salesforce';\nselect * from team_settings where team_id IN (526, 532);\n\nselect * from users where id IN (22824);\nselect * from crm_profiles where crm_configuration_id IN (1026);\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1093 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1099;\nselect * from users where id = 29643\n\nselect * from activity_processing_states;\n\nSELECT * FROM teams where name LIKE '%Fare%'; # 233\nSELECT * FROM opportunities where crm_configuration_id = 215\n# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'\n;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1088 and sa.provider = 'hubspot';\n\nSELECT * FROM teams order by updated_at DESC\nSELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account\n\nselect * from crm_configurations where provider = 'pipedrive';\n\nselect * from teams where id = 957;\nselect * from crm_configurations where id = 957;\n\nSELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743\nSELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;\n\nselect * from users where team_id = 1; # 26726 - Gabriela Dureva\nSELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific\nselect * from activities where user_id = 26726 order by id desc;\nselect * from contacts where crm_configuration_id = 1\nand email IN ('charlotte.ward@prolific.com', 'frankie.bryant@prolific.com'); # 2094416, 2093620\nSELECT * FROM contacts WHERE id = 6284931;\n\nSELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id\nWHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;\n\nselect * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);\nselect * from crm_configurations where id = 1;\n\n43801692-1aeb-32ce-acba-5b80a479701a\n44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b\n405975c0-b3d0-7aaa-821f-09d59cae6dd1\n4caf848d-4bed-2299-b248-7788d41f9fca\n49bedc3f-f196-eef3-89c3-dea6a3b4aa63\n43420989-a09d-b8f8-9806-c8bbf7a02aac\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nSELECT * FROM activities WHERE id = 75461988;\n\nSELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;\n\nselect * from contacts where id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nselect * from users where id = 21047;\nSELECT * FROM crm_configurations WHERE id = 892;\nSELECT * FROM teams WHERE id = 942;\nselect * from opportunities where team_id = 942 order by updated_at desc;\nselect * from contacts where team_id = 942 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 942 and sa.provider = 'hubspot';\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430\nSELECT * FROM crm_configurations WHERE id = 1;\nSELECT * FROM teams WHERE crm_id = 1;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1\nSELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430\n\nselect * from teams where id = 852;\nselect * from groups where id = 2286;\nselect * from sidekick_settings where team_id = 852;\nselect * from default_activity_types where team_id = 852;\n\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1 AND u.deleted_at IS NULL\nAND u.crm_required = 1\nAND u.team_id = 1\nORDER BY u.team_id;\n\nSELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (\n18481\n );\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1\n AND u.deleted_at IS NULL\n AND u.crm_required = 1\n# AND u.team_id = 1\n AND p.id IS NULL -- Move this condition to WHERE clause\nORDER BY u.team_id;\n\nSELECT * FROM opportunities WHERE id = 20002609;\nselect * from teams where id = 1122; # Velatir, 29953 - christian@velatir.com\nselect * from crm_configurations where id = 1060;\nselect * from crm_layouts where crm_configuration_id = 1060;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1122 and sa.provider = 'hubspot';\nselect * from opportunities where team_id = 1122 order by updated_at desc;\n\nselect * from crm_field_data where object_type = 'contact';\n\nSELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 248 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS\nSELECT * FROM users where id = 24115;\nSELECT * FROM accounts where id = 4002896;\nSELECT * FROM teams WHERE name LIKE '%adswerve%';\nSELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN (\"0069N000003GIQ9QAO\",\"0061r000019yGP9AAM\",\"0066900001S2KWlAAN\",\"0066900001TDpj2AAD\",\"0066900001b8uEwAAI\",\"0069N000001rQi0QAE\",\"006QF00000KD40mYAD\",\"006QF00000LzpRJYAZ\",\"0069N000002uomtQAA\",\"0069N000002xlMLQAY\",\"0066900001NV6ubAAD\",\"0061r00001HJp45AAD\",\"006QF00000uTlUoYAK\",\"006QF00000v0bZqYAI\");\nSELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203\n\nSELECT u.id, u.email, ac.name, a.* FROM activities a\nJOIN users u ON a.user_id = u.id\nJOIN accounts ac ON a.account_id = ac.id\nWHERE\nuuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or\nuuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or\nuuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;\n\nselect * from users where id = 5825;\nSELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;\n\nselect * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;\n19594, 862\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 862 and sa.provider = 'salesforce';\n\nselect * from automated_reports where id = 36;\nselect ar.frequency, r.*, ar.* from automated_report_results r\njoin automated_reports ar on r.report_id = ar.id\nwhere ar.frequency != 'one_off';\n\nselect s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;\nselect * from nudges n where n.activity_search_id\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;\n\nselect * from users where team_id = 1 and name like '%Lukas%'; # 7160\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\nSELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,\nselect * from opportunities where team_id = 1126;\nSELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,\nselect * from opportunities where team_id = 1125;\nselect * from contacts c\nwhere c.team_id = 882;\n\nSELECT * FROM activities WHERE id = 76822967;\nSELECT * FROM crm_profiles WHERE user_id = 15440;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 555;\nSELECT * FROM crm_configurations WHERE id = 555;\nSELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 581 and sa.provider = 'salesforce';\n\nSELECT * FROM automated_report_results order by id desc;\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556;\n\nselect * from automated_reports;\nwhere id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , [\"pdf\",\"podcast\"]\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\nselect * from automated_report_results order by id desc;\nSELECT * FROM automated_report_results WHERE id = 1919;\n\nselect * from automated_report_results WHERE report_id = 54;\n\nselect * from opportunities where id = 7594349;\n\nSELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - jiminnyintegration@lesmills.com\nselect * from playbooks where team_id = 711; # event 226147\nSELECT * FROM playbook_categories WHERE playbook_id = 5515;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';\nSELECT * FROM crm_fields WHERE id = 226147;\nSELECT * FROM crm_field_values WHERE crm_field_id = 226147;\n\nSELECT * FROM crm_configurations WHERE id = 692;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 711 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;\n\nselect * from leads;\n\nselect * from calendars;\n\nSELECT\n t.id AS team_id,\n t.name,\n LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain\nFROM teams t\nJOIN users u ON u.team_id = t.id\nJOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'\nLEFT JOIN team_domains td\n ON td.team_id = t.id\n AND td.deleted_at IS NULL\n AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))\nGROUP BY t.id, t.name, calendar_domain\nORDER BY t.name, calendar_domain;\n\nselect * from users u join calendars c on c.user_id = u.id\nwhere u.team_id = 882;\n\n\nselect * from activities where id = 74049485; # team 563 crm 537\nselect * from activities where id = 73272382; # team 563 crm 537\nselect * from activities where id = 64400389; # team 563 crm 537\nselect * from activities where id = 58081273; # team 563 crm 537\nselect * from activities where id = 54520297; # team 563 crm 537\nselect * from participants where activity_id = 58081273;\n\nselect * from activities where crm_configuration_id = 537 and provider = 'aircall'\nand account_id = 19003658 order by updated_at desc;\n\nselect * from contacts where crm_configuration_id = 537 and id = 35957759;\nselect * from accounts where crm_configuration_id = 537 and id = 19003658;\n\nselect * from automated_report_results where id = 1976;\nselect * from automated_reports where id = 583;\nselect * from activity_searches where id = 87714;\nselect * from activity_search_filters where activity_search_id = 87714;\n\nSELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid\nor uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot';\nselect * from rate_limits;\n\nselect * from automated_report_results where media_type = 'pdf' and status = 2\nand id IN (18, 1872);\nselect * from automated_reports where id = 54;\nSELECT * FROM users WHERE id IN (24623,29443,29613);\n\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\n\nselect * from text_relays where created_at > '2026-05-01'\n# and id IN (32415, 32416);\nand id = 32412;\n\nselect * from users where team_id = 2 and email like '%scott%' and id = 29510;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1852012799806863320
|
2218600140881933893
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20891-improve-sms-text Project: faVsco.js, menu
JY-20891-improve-sms-text-relays, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Jiminny\Listeners\Users;
use Illuminate\Mail\Events\MessageSending;
use Jiminny\Models\User;
use Jiminny\Repositories\UserRepository;
use Psr\Log\LoggerInterface;
class ValidateSendingMessage
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct(
private readonly LoggerInterface $logger,
private readonly UserRepository $userRepository
) {
}
/**
* Handle the event.
*/
public function handle(MessageSending $event): bool
{
$message = $event->message;
$recipients = $message->getTo();
$filteredRecipients = [];
foreach ($recipients as $recipient) {
$address = $recipient->getAddress();
$user = $this->userRepository->findByEmail($address);
// Only include active users in the filtered recipients list
if (! $user || $user->status === User::STATUS_ACTIVE) {
$filteredRecipients[] = $recipient;
} else {
$this->logger->info('[Mailer] Removing inactive user from recipients: ' . $address);
}
}
// If there are no recipients left after filtering, don't send the email
if (empty($filteredRecipients)) {
$this->logger->info('[Mailer] No active recipients left, cancelling email');
return false;
}
// Set the filtered recipients back to the message
$message->to(...$filteredRecipients);
return true;
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
40
1
40
65
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993
SELECT * FROM users WHERE id = 25061;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 994;
SELECT * FROM crm_profiles WHERE user_id = 25061;
select * from crm_configurations where id = 834;
SELECT * FROM teams WHERE id = 882;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;
SELECT * FROM contacts where crm_configuration_id = 834;
SELECT * FROM opportunities WHERE team_id = 933
# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');
AND id IN (8482561,18352941,19042734,19232139,19445140,19472541);
SELECT * FROM opportunity_contacts
WHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 485; #
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
select crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id
where crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')
# and l.converted_at IS NOT NULL
;
# [PASSWORD_DOTS]
SELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')
and opportunity_id IS NULL
order by id desc;
SELECT * FROM teams WHERE id = 604; # 598
SELECT * FROM activities WHERE id = 74410828; # [EMAIL]
SELECT * FROM accounts WHERE id = 20068382;
SELECT * FROM accounts WHERE id = 35186038;
SELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 559 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;
select * from sidekick_settings where team_id = 781;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100
SELECT * FROM crm_layouts WHERE crm_configuration_id = 711;
SELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL
and is_internal = 0 and status = 'completed'
order by id desc;
SELECT * FROM crm_layout_entities
WHERE crm_layout_id IN (2352, 2353);
;
SELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 556 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;
SELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;
select * from contacts
where crm_configuration_id = 530
and crm_provider_id = 872252;
select * from activities where crm_configuration_id = 530
and user_id = 14343 and type like '%softphone%'
and created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);
SELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t
JOIN crm_configurations c ON t.id = c.team_id
WHERE t.status = 'active';
SELECT * FROM teams where id = 1091;
SELECT * FROM crm_configurations where team_id = 1091;
SELECT * FROM activity_providers where team_id = 1091;
SELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT * FROM teams WHERE name LIKE '%Leadventure%';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1091 and sa.provider = 'salesforce';
SELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812
SELECT * FROM teams where id = 862;
SELECT * FROM crm_configurations where team_id = 862;
SELECT * FROM activity_providers where team_id = 862;
SELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT t.id, crm.id, crm.provider, ap.* FROM teams t
join crm_configurations crm on t.id = crm.team_id
join activity_providers ap on t.id = ap.team_id
where t.status = 'active' and ap.is_enabled = 1
and crm.provider = 'hubspot'
and ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',
'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');
SELECT * FROM teams where id = 1068;
SELECT * FROM crm_configurations where team_id = 1068;
SELECT * FROM activity_providers where team_id = 1068;
SELECT * FROM activities a
where crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')
and a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'
)
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by a.id desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1068 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262
SELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
select * from crm_layouts where crm_configuration_id = 834;
select * from crm_layout_entities where crm_layout_id = 2780;
select * from crm_fields where id IN (321153,321192,321193,321194);
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1057 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8
SELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20
SELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10
SELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #
SELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;
select * from users where team_id = 51; # 7783
SELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130
select * from activity_searches where user_id = 7783;
select * from activity_search_filters where activity_search_id IN (32291, 32292);
SELECT asf.activity_search_id, asf.id, asf.value
FROM activity_search_filters asf
WHERE asf.filter = 'group_id'
AND asf.value IN (
SELECT CONCAT(
HEX(SUBSTR(uuid, 5, 4)), '-',
HEX(SUBSTR(uuid, 3, 2)), '-',
HEX(SUBSTR(uuid, 1, 2)), '-',
HEX(SUBSTR(uuid, 9, 2)), '-',
HEX(SUBSTR(uuid, 11))
)
FROM groups
WHERE deleted_at IS NOT NULL
);
SELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where provider = 'hubspot';
SELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133
SELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null
# [PASSWORD_DOTS]
select * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';
select
cp.*
# DISTINCT t.id
# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields
FROM crm_profiles cp
JOIN crm_configurations crm on crm.id = cp.crm_configuration_id
JOIN users u on u.id = cp.user_id
JOIN teams t ON t.id = crm.team_id
WHERE crm.provider = 'salesforce' and t.status = 'active'
and cp.archived_at IS NULL and u.deleted_at IS NULL
and t.id NOT IN (1093)
and t.id = 2
and cp.contact_fields IS NULL;
# and c.crm_provider_id = '003Uu00000ojD4NIAU';
SELECT * FROM users WHERE id = 26484;
SELECT * FROM crm_profiles WHERE user_id = 26484;
SELECT * FROM social_accounts WHERE sociable_id = 26484;
SELECT * FROM crm_configurations where provider = 'salesforce';
select * from users where id IN (10022, 10403);
select * from users where team_id IN (526);
select * from teams where id IN (526, 532);
select * from crm_configurations where id IN (500, 516);
select * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);
select * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 526 and sa.provider = 'salesforce';
select * from team_settings where team_id IN (526, 532);
select * from users where id IN (22824);
select * from crm_profiles where crm_configuration_id IN (1026);
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1093 and sa.provider = 'salesforce';
select * from teams where id = 1099;
select * from users where id = 29643
select * from activity_processing_states;
SELECT * FROM teams where name LIKE '%Fare%'; # 233
SELECT * FROM opportunities where crm_configuration_id = 215
# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'
;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1088 and sa.provider = 'hubspot';
SELECT * FROM teams order by updated_at DESC
SELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account
select * from crm_configurations where provider = 'pipedrive';
select * from teams where id = 957;
select * from crm_configurations where id = 957;
SELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743
SELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;
select * from users where team_id = 1; # 26726 - Gabriela Dureva
SELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific
select * from activities where user_id = 26726 order by id desc;
select * from contacts where crm_configuration_id = 1
and email IN ('[EMAIL]', '[EMAIL]'); # 2094416, 2093620
SELECT * FROM contacts WHERE id = 6284931;
SELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id
WHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;
select * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);
select * from crm_configurations where id = 1;
43801692-1aeb-32ce-acba-5b80a479701a
44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b
405975c0-b3d0-7aaa-821f-09d59cae6dd1
4caf848d-4bed-2299-b248-7788d41f9fca
49bedc3f-f196-eef3-89c3-dea6a3b4aa63
43420989-a09d-b8f8-9806-c8bbf7a02aac
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
SELECT * FROM activities WHERE id = 75461988;
SELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;
select * from contacts where id = 17900517;
select * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id
where crm.provider != 'salesforce';
select * from users where id = 21047;
SELECT * FROM crm_configurations WHERE id = 892;
SELECT * FROM teams WHERE id = 942;
select * from opportunities where team_id = 942 order by updated_at desc;
select * from contacts where team_id = 942 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 942 and sa.provider = 'hubspot';
SELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430
SELECT * FROM crm_configurations WHERE id = 1;
SELECT * FROM teams WHERE crm_id = 1;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
select id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1
SELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430
select * from teams where id = 852;
select * from groups where id = 2286;
select * from sidekick_settings where team_id = 852;
select * from default_activity_types where team_id = 852;
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1 AND u.deleted_at IS NULL
AND u.crm_required = 1
AND u.team_id = 1
ORDER BY u.team_id;
SELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (
18481
);
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1
AND u.deleted_at IS NULL
AND u.crm_required = 1
# AND u.team_id = 1
AND p.id IS NULL -- Move this condition to WHERE clause
ORDER BY u.team_id;
SELECT * FROM opportunities WHERE id = 20002609;
select * from teams where id = 1122; # Velatir, 29953 - [EMAIL]
select * from crm_configurations where id = 1060;
select * from crm_layouts where crm_configuration_id = 1060;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1122 and sa.provider = 'hubspot';
select * from opportunities where team_id = 1122 order by updated_at desc;
select * from crm_field_data where object_type = 'contact';
SELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 248 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS
SELECT * FROM users where id = 24115;
SELECT * FROM accounts where id = 4002896;
SELECT * FROM teams WHERE name LIKE '%adswerve%';
SELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN ("0069N000003GIQ9QAO","0061r000019yGP9AAM","0066900001S2KWlAAN","0066900001TDpj2AAD","0066900001b8uEwAAI","0069N000001rQi0QAE","006QF00000KD40mYAD","006QF00000LzpRJYAZ","0069N000002uomtQAA","0069N000002xlMLQAY","0066900001NV6ubAAD","0061r00001HJp45AAD","006QF00000uTlUoYAK","006QF00000v0bZqYAI");
SELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203
SELECT u.id, u.email, ac.name, a.* FROM activities a
JOIN users u ON a.user_id = u.id
JOIN accounts ac ON a.account_id = ac.id
WHERE
uuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or
uuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or
uuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;
select * from users where id = 5825;
SELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;
select * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;
19594, 862
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 862 and sa.provider = 'salesforce';
select * from automated_reports where id = 36;
select ar.frequency, r.*, ar.* from automated_report_results r
join automated_reports ar on r.report_id = ar.id
where ar.frequency != 'one_off';
select s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;
select * from nudges n where n.activity_search_id
select * from teams where created_at > '2026-03-09';
SELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;
select * from users where team_id = 1 and name like '%Lukas%'; # 7160
SELECT * FROM teams WHERE id = 575;
select * from opportunities where team_id = 575;
SELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,
select * from opportunities where team_id = 1126;
SELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,
select * from opportunities where team_id = 1125;
select * from contacts c
where c.team_id = 882;
SELECT * FROM activities WHERE id = 76822967;
SELECT * FROM crm_profiles WHERE user_id = 15440;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 555;
SELECT * FROM crm_configurations WHERE id = 555;
SELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 581 and sa.provider = 'salesforce';
SELECT * FROM automated_report_results order by id desc;
select * from features;
select * from team_features where feature_id = 40;
select * from teams where id = 556;
select * from automated_reports;
where id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , ["pdf","podcast"]
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from automated_report_results order by id desc;
SELECT * FROM automated_report_results WHERE id = 1919;
select * from automated_report_results WHERE report_id = 54;
select * from opportunities where id = 7594349;
SELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - [EMAIL]
select * from playbooks where team_id = 711; # event 226147
SELECT * FROM playbook_categories WHERE playbook_id = 5515;
SELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';
SELECT * FROM crm_fields WHERE id = 226147;
SELECT * FROM crm_field_values WHERE crm_field_id = 226147;
SELECT * FROM crm_configurations WHERE id = 692;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 711 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;
select * from leads;
select * from calendars;
SELECT
t.id AS team_id,
t.name,
LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain
FROM teams t
JOIN users u ON u.team_id = t.id
JOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'
LEFT JOIN team_domains td
ON td.team_id = t.id
AND td.deleted_at IS NULL
AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))
GROUP BY t.id, t.name, calendar_domain
ORDER BY t.name, calendar_domain;
select * from users u join calendars c on c.user_id = u.id
where u.team_id = 882;
select * from activities where id = 74049485; # team 563 crm 537
select * from activities where id = 73272382; # team 563 crm 537
select * from activities where id = 64400389; # team 563 crm 537
select * from activities where id = 58081273; # team 563 crm 537
select * from activities where id = 54520297; # team 563 crm 537
select * from participants where activity_id = 58081273;
select * from activities where crm_configuration_id = 537 and provider = 'aircall'
and account_id = 19003658 order by updated_at desc;
select * from contacts where crm_configuration_id = 537 and id = 35957759;
select * from accounts where crm_configuration_id = 537 and id = 19003658;
select * from automated_report_results where id = 1976;
select * from automated_reports where id = 583;
select * from activity_searches where id = 87714;
select * from activity_search_filters where activity_search_id = 87714;
SELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid
or uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;
SELECT * FROM crm_configurations WHERE provider = 'hubspot';
select * from rate_limits;
select * from automated_report_results where media_type = 'pdf' and status = 2
and id IN (18, 1872);
select * from automated_reports where id = 54;
SELECT * FROM users WHERE id IN (24623,29443,29613);
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from text_relays where created_at > '2026-05-01'
# and id IN (32415, 32416);
and id = 32412;
select * from users where team_id = 2 and email like '%scott%' and id = 29510;
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
34479
|
1294
|
5
|
2026-05-13T11:18:30.367614+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-13/1778 /Users/lukas/.screenpipe/data/data/2026-05-13/1778671110367_m2.jpg...
|
PhpStorm
|
faVsco.js – ValidateSendingMessage.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicareCodeLaravelKeractorWindowFV f PhostormVIewINavicareCodeLaravelKeractorWindowFV faVsco.js°9 master kProiect(C) UserinvitationDTo.ong© MailboxController.php(C) ReindexForGrounListeC) TextRelayService.pnp© SsoController.phpReindexForLeadListen© SmsRelayFailed.phpsms-relay-failed.blade.phg©smsmessage.ong© SmsLength.phpLo kelndexroropportunit© ReindexForUserListen©Textkelay.pnpOkesolverrovider.onp?phoo senecxooncmall.ong© SetupintegrationActiornamespace Jiminny Listeners UsersC UserPilotActivityListenAuthenticationuse Luminate raru events messagesendino?_ Automateakeporisuse Jiminny Models User:• calendarsaermuse Psr Loo LoggerInterface:_ Dealkisksu lasucsearchclass Val.idateSendingMessage* Crente the event listenenNudaesUserPilot* @return void© CreateNudgeCreatedE 16> Opportunitiesv MPlavbookspublic function -_construct(private readonly LoggerInterface $Logger)C) AttachLavout.oho(C) ChandeSidekickSettindpubLic tunction -_constructlorivate readonly Loggerinterface Slogger(C) CreatePlavbookCreatev M Dlavlicteprivate readonly UserRepository SuserRepositoryDК...H>• Activitiesv M Planhat@ CreateActivitvAdde 23MuicerDilot9 ImnortGrounDlavlictSh 25m Teamsnublic function handle(MessageSendina Sevent): hool-...?D TranscriptionC UsersActivityProviderDiscon(C) ActivityProviderinteare€ CreateDefaultPlavlist.r© CreateDefaultSavedSeC) CreateGroupCreatedEP NotifvCrmOwnerDisco@ NotifvCrmUserDisconr(C) SetDefaultAvatarLister© SetupCalendarSvnc.ohC) SetuoMailSvnc.ohvC) Svncintercom.oho(C) SvncPlanhat. oho(c) SunceliserDilot nhnMWehhookAccept File *~ X Reject File + * €< 4 of 4 files →suppont Dally • In 42m100% S2• Wed 13 May 14:18:30U AskJiminnyReportActivityServiceTest v= custom.log|aravel.l0gA SF (jiminny@localhost]4 HS_local [jiminny@localhost]& console (PROD] X A console [EU]cascadeA console [STAGING]6316326331-634635636— 63% €661662663664669666 VTrial Owner Role SeleSMS Fallure Email li+0 ..Tx: Auto vSo jiminnyTnoughtror is>AND to.domain = LOWERSUBSTRING INDEX(c.calendar provider id. '0'. -1)040 A1 A40 V 65 ^GROUP BY t.id, t.name, calendar_domainORDER BY t.name. calendar domain:select * from users u join calendars c 1<->l.n: on c.user_id = u.idwhere u.tean id = 880select * from activities where id = 74049485• # team 563 com 537.select * from activities where id = 73272382; # team 563 crm 537select * from activities where id = 64400389; # team 563 crm 537select * from activities where id = 58081273; # team 563 crm 537select * from activities where id = 54520297; # team 563 crm 537select * from participants where activity id = 58081273:select * from activities where crm_confiquration id = 537 and provider = 'aircall)and account id = 19003658 order by updated at desc:select * from contacts where crm confiquration id = 537 and id = 35957759:select * from accounts where erm confiquration id = 537 and id = 19003658:select * from automated report results where id = 1976:select * from automated_reports where id = 583;select * from activity searches where id = 87714:select * from activity search filters where activity search_id = 87714:SELECT * FROM activities WHERE uuid to bin('88274672-202d-4162-9d04-73f£5f0566a9') = uuidon uuid to bin(:47842446-af51-4bch-854f-cc6560290101') = uuid:SELECT * FROM crm_configurations WHERE provider = 'hubspot' ;select * from rate_limits;select * from autoand id IN (18, 1872);select * from automated_reports where id = 54;SELECT * FROM users WHERE id IN (24623,29443,29613)=lated_report_results where media type = 'pdf' and status = 2SELECT * FROM automated_report_ results WHERE uuid to bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;select * from text_relays where created_at > '2026-05-01)# and id IN (32415, 32416);And ¿d - 20119.select * from users where team id = 2 and email like '%scott%' and id = 29510:Tnoughttor zs>The lserRenository already has a findBvEmailo method that checks both emait and secondary emait (unes sos"366). I'll update EmailTextRelay-php to use this repository method instead of the direct query.Read EmailTextRelav.oho #L1-30." EmailTextRelav.ohoRead EmallTextRelay.php #L/5-94Thought for 1s>EmailTextRelay.phpThoughts• EmailTextRelay.phpThought for 1sRead Sco nhnSso.nholThouahts• Sso.phpRead SsoController.ohvInougnts•Ssocontroller.phpSsoController.php• SsoController.phpRead ValidateSendinaMessage.ohv• ValidateSendingMessage.php"ValidateSendinaMessaae.ohvDiving+1-1+1-1+7-2+2 -11+1-1+4-2* Reiect alliiAccent allAsk anvthina (84L)O IDE error occurredSee details and submit report« Code SWF-1.6W Windsurf Teams7-1UTE.8io 4 spacesw...
|
NULL
|
-9190839831643216223
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormVIewINavicareCodeLaravelKeractorWindowFV f PhostormVIewINavicareCodeLaravelKeractorWindowFV faVsco.js°9 master kProiect(C) UserinvitationDTo.ong© MailboxController.php(C) ReindexForGrounListeC) TextRelayService.pnp© SsoController.phpReindexForLeadListen© SmsRelayFailed.phpsms-relay-failed.blade.phg©smsmessage.ong© SmsLength.phpLo kelndexroropportunit© ReindexForUserListen©Textkelay.pnpOkesolverrovider.onp?phoo senecxooncmall.ong© SetupintegrationActiornamespace Jiminny Listeners UsersC UserPilotActivityListenAuthenticationuse Luminate raru events messagesendino?_ Automateakeporisuse Jiminny Models User:• calendarsaermuse Psr Loo LoggerInterface:_ Dealkisksu lasucsearchclass Val.idateSendingMessage* Crente the event listenenNudaesUserPilot* @return void© CreateNudgeCreatedE 16> Opportunitiesv MPlavbookspublic function -_construct(private readonly LoggerInterface $Logger)C) AttachLavout.oho(C) ChandeSidekickSettindpubLic tunction -_constructlorivate readonly Loggerinterface Slogger(C) CreatePlavbookCreatev M Dlavlicteprivate readonly UserRepository SuserRepositoryDК...H>• Activitiesv M Planhat@ CreateActivitvAdde 23MuicerDilot9 ImnortGrounDlavlictSh 25m Teamsnublic function handle(MessageSendina Sevent): hool-...?D TranscriptionC UsersActivityProviderDiscon(C) ActivityProviderinteare€ CreateDefaultPlavlist.r© CreateDefaultSavedSeC) CreateGroupCreatedEP NotifvCrmOwnerDisco@ NotifvCrmUserDisconr(C) SetDefaultAvatarLister© SetupCalendarSvnc.ohC) SetuoMailSvnc.ohvC) Svncintercom.oho(C) SvncPlanhat. oho(c) SunceliserDilot nhnMWehhookAccept File *~ X Reject File + * €< 4 of 4 files →suppont Dally • In 42m100% S2• Wed 13 May 14:18:30U AskJiminnyReportActivityServiceTest v= custom.log|aravel.l0gA SF (jiminny@localhost]4 HS_local [jiminny@localhost]& console (PROD] X A console [EU]cascadeA console [STAGING]6316326331-634635636— 63% €661662663664669666 VTrial Owner Role SeleSMS Fallure Email li+0 ..Tx: Auto vSo jiminnyTnoughtror is>AND to.domain = LOWERSUBSTRING INDEX(c.calendar provider id. '0'. -1)040 A1 A40 V 65 ^GROUP BY t.id, t.name, calendar_domainORDER BY t.name. calendar domain:select * from users u join calendars c 1<->l.n: on c.user_id = u.idwhere u.tean id = 880select * from activities where id = 74049485• # team 563 com 537.select * from activities where id = 73272382; # team 563 crm 537select * from activities where id = 64400389; # team 563 crm 537select * from activities where id = 58081273; # team 563 crm 537select * from activities where id = 54520297; # team 563 crm 537select * from participants where activity id = 58081273:select * from activities where crm_confiquration id = 537 and provider = 'aircall)and account id = 19003658 order by updated at desc:select * from contacts where crm confiquration id = 537 and id = 35957759:select * from accounts where erm confiquration id = 537 and id = 19003658:select * from automated report results where id = 1976:select * from automated_reports where id = 583;select * from activity searches where id = 87714:select * from activity search filters where activity search_id = 87714:SELECT * FROM activities WHERE uuid to bin('88274672-202d-4162-9d04-73f£5f0566a9') = uuidon uuid to bin(:47842446-af51-4bch-854f-cc6560290101') = uuid:SELECT * FROM crm_configurations WHERE provider = 'hubspot' ;select * from rate_limits;select * from autoand id IN (18, 1872);select * from automated_reports where id = 54;SELECT * FROM users WHERE id IN (24623,29443,29613)=lated_report_results where media type = 'pdf' and status = 2SELECT * FROM automated_report_ results WHERE uuid to bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;select * from text_relays where created_at > '2026-05-01)# and id IN (32415, 32416);And ¿d - 20119.select * from users where team id = 2 and email like '%scott%' and id = 29510:Tnoughttor zs>The lserRenository already has a findBvEmailo method that checks both emait and secondary emait (unes sos"366). I'll update EmailTextRelay-php to use this repository method instead of the direct query.Read EmailTextRelav.oho #L1-30." EmailTextRelav.ohoRead EmallTextRelay.php #L/5-94Thought for 1s>EmailTextRelay.phpThoughts• EmailTextRelay.phpThought for 1sRead Sco nhnSso.nholThouahts• Sso.phpRead SsoController.ohvInougnts•Ssocontroller.phpSsoController.php• SsoController.phpRead ValidateSendinaMessage.ohv• ValidateSendingMessage.php"ValidateSendinaMessaae.ohvDiving+1-1+1-1+7-2+2 -11+1-1+4-2* Reiect alliiAccent allAsk anvthina (84L)O IDE error occurredSee details and submit report« Code SWF-1.6W Windsurf Teams7-1UTE.8io 4 spacesw...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
34478
|
1293
|
3
|
2026-05-13T11:18:29.888616+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-13/1778 /Users/lukas/.screenpipe/data/data/2026-05-13/1778671109888_m1.jpg...
|
PhpStorm
|
faVsco.js – ValidateSendingMessage.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpPRODIDOCKER$81 SlackFileEditViewGoHistoryWindowHelpPRODIDOCKER$81DEV (docker)₴82APP (-zshDOCKER (docker-compose)docker_lamp_1/proc/1/fd/1'1 '/usr/local/bin/php' 'artisan' jiminny:playlists:normalize-sort › '2>&1docker_1amp_1docker_lamp_1docker_lamp_1docker_1amp_1run_artisan_schedule: Done waiting for schedule:run2026-05-13 11:17:08 Running ['artisan'Paceing bot scoredat e soe19sDONEdocker_1amp_1'/usr/local/bin/php' 'artisan'meeting-bot: schedule-bot > */proc/1/fd/1'2>&1elasticsearch1 {"type": "server""timestamp": "2026-05-13T11:17:26, 690Z","Level": "1NFO","component":"o.e.m. j.JvmGcMonitorService"e.name":"e802ad473a4f", "cluster .name":"docker-cluster""nod"message": "[gc] [6214] overhead,spent [497ms] collecting in the last [1.4s]", "cluster.uuid": "8uhZw1CUSGyWYR_OvaKx6g" ,"node.id" : "e2ZKzgw4Q4aCf2w51jWr1A"}docker_lamp_12026-05-13 11:17:19 Running ['artisan' dialers:monitor-activities] 1OS DONEdocker_lamp_11 '/usr/local/bin/php' 'artisan' dialers:monitor-activities › */proc/1/fd/1' 2>&12026-05-13 11:17:29 Running ['artisan' jiminny:monitor-social-accountdocker_lamp_1proc/1/fd/1' 2>&1docker_1amp_19s DONEdocker_lamp_11/fd/1' 2>&1docker_lamp_1tches=15]85DONEdocker_lamp_115 >*/proc/1/fd/1' 2>&1docker_lamp_1 |9s DONEdocker_1amp_12>&1docker_lamp_1docker_lamp_11RUNNINGdocker_1amp_1docker_lamp_1msDONE1 '/usr/local/bin/php' 'artisan' jiminny:monitor-social-accounts > '/2026-05-13 11:17:39 Running ['artisan' mailbox:skip-lists:refresh].1 '/usr/local/bin/php' 'artisan' mailbox:skip-lists:refresh › '/proc/2026-05-13 11:17:48 Running ['artisan' mailbox:batch:process --max-ba1 '/usr/local/bin/php' 'artisan'mailbox:batch:process --max-batches=2026-05-13 11:17:56 Running ['artisan' mailbox:batch:create]1 • '/usr/local/bin/php' 'artisan'mailbox:batch: create › '/proc/1/fd/12026-05-13 11:18:06 Jiminny\Jobs\Mailbox\CreateBatches1 run_artisan_schedule: Done waiting forschedule: run2026-05-13 11:18:06 Jiminny\Jobs\Mailbox\CreateBatches426.72View in Docker Desktopo View Configw Enable WatchHomeDMsActivityFilesLaterMoreED→Jiminny ...ab External connections* Starred& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general#jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of jimi...° Direct messages. Nikolay Ivanovd. James Graham. Stoyan TanevP. Galya DimitrovaSupport Daily • in 42 m100% <2Wed 13 May 14:18:29Describe what you are looking for# platform-tickets8146 0• Messagest Add canvasO Filesmhe PrioritThursday, April 16th~+Tuesday, May 5th ~Automation for Jira APP2:12 PMSRD-6835 Salesforce app issues has been raisedwith Platform team and assigned to **. The Priorityis P2 MediumAutomation for Jira APP5:01 PMSRD-6815 [Playback] Ask Jiminny odd behaviourhas been raised with Platform team and assigned toSteliyan Georgiev. The Priority is P2 MediumMonday, May 11th ~Automation for Jira APP 4:38 PMSRD-6845 CRM filling is not being applied has beenraised with Platform team and assigned to **. ThePriority is **Today ~Automation for Jira APP10:42 AMSRD-6848 Sidekick SMS issue has been raised withPlatform team and assigned to **. The Priority is **NewAutomation for Jira APP 1:33 PMSRD-6849 Recorded call does not appear on thedashboard has been raised with Platform team andassigned to **. The Priority is **Message #platform-tickets...
|
NULL
|
5428587134375435767
|
NULL
|
idle
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpPRODIDOCKER$81 SlackFileEditViewGoHistoryWindowHelpPRODIDOCKER$81DEV (docker)₴82APP (-zshDOCKER (docker-compose)docker_lamp_1/proc/1/fd/1'1 '/usr/local/bin/php' 'artisan' jiminny:playlists:normalize-sort › '2>&1docker_1amp_1docker_lamp_1docker_lamp_1docker_1amp_1run_artisan_schedule: Done waiting for schedule:run2026-05-13 11:17:08 Running ['artisan'Paceing bot scoredat e soe19sDONEdocker_1amp_1'/usr/local/bin/php' 'artisan'meeting-bot: schedule-bot > */proc/1/fd/1'2>&1elasticsearch1 {"type": "server""timestamp": "2026-05-13T11:17:26, 690Z","Level": "1NFO","component":"o.e.m. j.JvmGcMonitorService"e.name":"e802ad473a4f", "cluster .name":"docker-cluster""nod"message": "[gc] [6214] overhead,spent [497ms] collecting in the last [1.4s]", "cluster.uuid": "8uhZw1CUSGyWYR_OvaKx6g" ,"node.id" : "e2ZKzgw4Q4aCf2w51jWr1A"}docker_lamp_12026-05-13 11:17:19 Running ['artisan' dialers:monitor-activities] 1OS DONEdocker_lamp_11 '/usr/local/bin/php' 'artisan' dialers:monitor-activities › */proc/1/fd/1' 2>&12026-05-13 11:17:29 Running ['artisan' jiminny:monitor-social-accountdocker_lamp_1proc/1/fd/1' 2>&1docker_1amp_19s DONEdocker_lamp_11/fd/1' 2>&1docker_lamp_1tches=15]85DONEdocker_lamp_115 >*/proc/1/fd/1' 2>&1docker_lamp_1 |9s DONEdocker_1amp_12>&1docker_lamp_1docker_lamp_11RUNNINGdocker_1amp_1docker_lamp_1msDONE1 '/usr/local/bin/php' 'artisan' jiminny:monitor-social-accounts > '/2026-05-13 11:17:39 Running ['artisan' mailbox:skip-lists:refresh].1 '/usr/local/bin/php' 'artisan' mailbox:skip-lists:refresh › '/proc/2026-05-13 11:17:48 Running ['artisan' mailbox:batch:process --max-ba1 '/usr/local/bin/php' 'artisan'mailbox:batch:process --max-batches=2026-05-13 11:17:56 Running ['artisan' mailbox:batch:create]1 • '/usr/local/bin/php' 'artisan'mailbox:batch: create › '/proc/1/fd/12026-05-13 11:18:06 Jiminny\Jobs\Mailbox\CreateBatches1 run_artisan_schedule: Done waiting forschedule: run2026-05-13 11:18:06 Jiminny\Jobs\Mailbox\CreateBatches426.72View in Docker Desktopo View Configw Enable WatchHomeDMsActivityFilesLaterMoreED→Jiminny ...ab External connections* Starred& jiminny-x-integrati...8platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# curiosity_lab# engineering# general#jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of jimi...° Direct messages. Nikolay Ivanovd. James Graham. Stoyan TanevP. Galya DimitrovaSupport Daily • in 42 m100% <2Wed 13 May 14:18:29Describe what you are looking for# platform-tickets8146 0• Messagest Add canvasO Filesmhe PrioritThursday, April 16th~+Tuesday, May 5th ~Automation for Jira APP2:12 PMSRD-6835 Salesforce app issues has been raisedwith Platform team and assigned to **. The Priorityis P2 MediumAutomation for Jira APP5:01 PMSRD-6815 [Playback] Ask Jiminny odd behaviourhas been raised with Platform team and assigned toSteliyan Georgiev. The Priority is P2 MediumMonday, May 11th ~Automation for Jira APP 4:38 PMSRD-6845 CRM filling is not being applied has beenraised with Platform team and assigned to **. ThePriority is **Today ~Automation for Jira APP10:42 AMSRD-6848 Sidekick SMS issue has been raised withPlatform team and assigned to **. The Priority is **NewAutomation for Jira APP 1:33 PMSRD-6849 Recorded call does not appear on thedashboard has been raised with Platform team andassigned to **. The Priority is **Message #platform-tickets...
|
34475
|
NULL
|
NULL
|
NULL
|
|
34477
|
1294
|
4
|
2026-05-13T11:18:27.307848+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-13/1778 /Users/lukas/.screenpipe/data/data/2026-05-13/1778671107307_m2.jpg...
|
PhpStorm
|
faVsco.js – ValidateSendingMessage.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
IDE error occurred
text/html
text/html
text/html
S IDE error occurred
text/html
text/html
text/html
See details and submit report
Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Jiminny\Listeners\Users;
use Illuminate\Mail\Events\MessageSending;
use Jiminny\Models\User;
use Jiminny\Repositories\UserRepository;
use Psr\Log\LoggerInterface;
class ValidateSendingMessage
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct(private readonly LoggerInterface $logger)
{
}
/**
* Handle the event.
*/
public function handle(MessageSending $event): bool
{
$message = $event->message;
$recipients = $message->getTo();
$filteredRecipients = [];
foreach ($recipients as $recipient) {
$address = $recipient->getAddress();
$user = User::where('email', $address)->first();
// Only include active users in the filtered recipients list
if (! $user || $user->status === User::STATUS_ACTIVE) {
$filteredRecipients[] = $recipient;
} else {
$this->logger->info('[Mailer] Removing inactive user from recipients: ' . $address);
}
}
// If there are no recipients left after filtering, don't send the email
if (empty($filteredRecipients)) {
$this->logger->info('[Mailer] No active recipients left, cancelling email');
return false;
}
// Set the filtered recipients back to the message
$message->to(...$filteredRecipients);
return true;
}
}
Execute
Explain Plan
Browse Query History...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"IDE error occurred","depth":3,"bounds":{"left":0.8753325,"top":0.92577815,"width":0.11037234,"height":0.013567438},"on_screen":true,"value":"IDE error occurred","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.92577815,"width":0.03756649,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"See details and submit report","depth":2,"bounds":{"left":0.8753325,"top":0.943336,"width":0.06017287,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"master, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.040226065,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: master<br/>Some incoming commits are not fetched<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Listeners\\Users;\n\nuse Illuminate\\Mail\\Events\\MessageSending;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\UserRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass ValidateSendingMessage\n{\n /**\n * Create the event listener.\n *\n * @return void\n */\n public function __construct(private readonly LoggerInterface $logger)\n {\n }\n\n /**\n * Handle the event.\n */\n public function handle(MessageSending $event): bool\n {\n $message = $event->message;\n $recipients = $message->getTo();\n $filteredRecipients = [];\n\n foreach ($recipients as $recipient) {\n $address = $recipient->getAddress();\n $user = User::where('email', $address)->first();\n\n // Only include active users in the filtered recipients list\n if (! $user || $user->status === User::STATUS_ACTIVE) {\n $filteredRecipients[] = $recipient;\n } else {\n $this->logger->info('[Mailer] Removing inactive user from recipients: ' . $address);\n }\n }\n\n // If there are no recipients left after filtering, don't send the email\n if (empty($filteredRecipients)) {\n $this->logger->info('[Mailer] No active recipients left, cancelling email');\n\n return false;\n }\n\n // Set the filtered recipients back to the message\n $message->to(...$filteredRecipients);\n\n return true;\n }\n}","depth":4,"bounds":{"left":0.11968085,"top":0.14684756,"width":0.28922874,"height":0.8324022},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Listeners\\Users;\n\nuse Illuminate\\Mail\\Events\\MessageSending;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\UserRepository;\nuse Psr\\Log\\LoggerInterface;\n\nclass ValidateSendingMessage\n{\n /**\n * Create the event listener.\n *\n * @return void\n */\n public function __construct(private readonly LoggerInterface $logger)\n {\n }\n\n /**\n * Handle the event.\n */\n public function handle(MessageSending $event): bool\n {\n $message = $event->message;\n $recipients = $message->getTo();\n $filteredRecipients = [];\n\n foreach ($recipients as $recipient) {\n $address = $recipient->getAddress();\n $user = User::where('email', $address)->first();\n\n // Only include active users in the filtered recipients list\n if (! $user || $user->status === User::STATUS_ACTIVE) {\n $filteredRecipients[] = $recipient;\n } else {\n $this->logger->info('[Mailer] Removing inactive user from recipients: ' . $address);\n }\n }\n\n // If there are no recipients left after filtering, don't send the email\n if (empty($filteredRecipients)) {\n $this->logger->info('[Mailer] No active recipients left, cancelling email');\n\n return false;\n }\n\n // Set the filtered recipients back to the message\n $message->to(...$filteredRecipients);\n\n return true;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.40957448,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.41821808,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"bounds":{"left":0.42918882,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-4106763515497119650
|
-8424963813457879228
|
visual_change
|
accessibility
|
NULL
|
IDE error occurred
text/html
text/html
text/html
S IDE error occurred
text/html
text/html
text/html
See details and submit report
Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Jiminny\Listeners\Users;
use Illuminate\Mail\Events\MessageSending;
use Jiminny\Models\User;
use Jiminny\Repositories\UserRepository;
use Psr\Log\LoggerInterface;
class ValidateSendingMessage
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct(private readonly LoggerInterface $logger)
{
}
/**
* Handle the event.
*/
public function handle(MessageSending $event): bool
{
$message = $event->message;
$recipients = $message->getTo();
$filteredRecipients = [];
foreach ($recipients as $recipient) {
$address = $recipient->getAddress();
$user = User::where('email', $address)->first();
// Only include active users in the filtered recipients list
if (! $user || $user->status === User::STATUS_ACTIVE) {
$filteredRecipients[] = $recipient;
} else {
$this->logger->info('[Mailer] Removing inactive user from recipients: ' . $address);
}
}
// If there are no recipients left after filtering, don't send the email
if (empty($filteredRecipients)) {
$this->logger->info('[Mailer] No active recipients left, cancelling email');
return false;
}
// Set the filtered recipients back to the message
$message->to(...$filteredRecipients);
return true;
}
}
Execute
Explain Plan
Browse Query History...
|
34476
|
NULL
|
NULL
|
NULL
|
|
76187
|
2686
|
16
|
2026-05-27T09:33:12.814432+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-27/1779 /Users/lukas/.screenpipe/data/data/2026-05-27/1779874392814_m1.jpg...
|
PhpStorm
|
faVsco.js – UserRoleObserver.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
+SlackFileEditHistoryWindow Help• 0QViewGoKioskOrg +SlackFileEditHistoryWindow Help• 0QViewGoKioskOrganizationsSetup AccountUsersActivities- jupiter.staging.jiminny.com/kiosk/onboard-listAutomated ReportsMobile version• Search |NAMEAudi Motors• 0HomeDMsActivityFilesLater•*•MoreED→Jiminny ...Ab External connections# Starred& jiminny-x-integrati...8 platform-backend-...8 platform-inner-teamE Channels# ai-chapter#alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...° Direct messagesStalivan Coorciov• Unread mentions100% <78• Wed 27 May 12:33:12Describe what you are looking forSteliyan Georgiev6 0• Messagest* Add canvasO Files+Allow oToday~to be selected whe...Story JY-2U613 in Jira CloudStatusDeployedPriority= MediumAssigneeLukas Koval...As of today at 11:21 AMOpen in Jira+ Summarizeсамо да видя къде може да се покажеd 1Lukas Kovalik :11:35 AMhttps://jupiter.staging.jiminny.com/kiosk/onboard-listако искаш да се чуем на бързоSteliyan Georgiev 11:36 AMдай ми 3 мин и ще ти звъннаLukas Kovalik ®! 11:36 AMOKA huddle happened 11:39 AMYou and Steliyan Georgiev were in the huddle for4m.Message Steliyan Georgiev...Lộ3JY-20855-mcp-ES-mappings = 892937...
|
NULL
|
-5835986181265617214
|
NULL
|
visual_change
|
ocr
|
NULL
|
+SlackFileEditHistoryWindow Help• 0QViewGoKioskOrg +SlackFileEditHistoryWindow Help• 0QViewGoKioskOrganizationsSetup AccountUsersActivities- jupiter.staging.jiminny.com/kiosk/onboard-listAutomated ReportsMobile version• Search |NAMEAudi Motors• 0HomeDMsActivityFilesLater•*•MoreED→Jiminny ...Ab External connections# Starred& jiminny-x-integrati...8 platform-backend-...8 platform-inner-teamE Channels# ai-chapter#alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...° Direct messagesStalivan Coorciov• Unread mentions100% <78• Wed 27 May 12:33:12Describe what you are looking forSteliyan Georgiev6 0• Messagest* Add canvasO Files+Allow oToday~to be selected whe...Story JY-2U613 in Jira CloudStatusDeployedPriority= MediumAssigneeLukas Koval...As of today at 11:21 AMOpen in Jira+ Summarizeсамо да видя къде може да се покажеd 1Lukas Kovalik :11:35 AMhttps://jupiter.staging.jiminny.com/kiosk/onboard-listако искаш да се чуем на бързоSteliyan Georgiev 11:36 AMдай ми 3 мин и ще ти звъннаLukas Kovalik ®! 11:36 AMOKA huddle happened 11:39 AMYou and Steliyan Georgiev were in the huddle for4m.Message Steliyan Georgiev...Lộ3JY-20855-mcp-ES-mappings = 892937...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
34752
|
1300
|
26
|
2026-05-13T11:36:50.049198+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-13/1778 /Users/lukas/.screenpipe/data/data/2026-05-13/1778672210049_m2.jpg...
|
PhpStorm
|
faVsco.js – UserRepository.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20891-improve-sms-text Project: faVsco.js, menu
JY-20891-improve-sms-text-relays, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Repositories;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Jiminny\Component\Cache;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\UserRepository as UserRepositoryContract;
use Jiminny\Models\Contracts\UserContract;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Group;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Participant;
use Jiminny\Models\Partner;
use Jiminny\Models\Playlist;
use Jiminny\Models\Playlist\Share;
use Jiminny\Models\Role;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Jiminny\Models\UserSettings;
class UserRepository implements UserRepositoryContract
{
private Cache\Service\Storage $storage;
public function __construct(Cache\Service\Storage $storage)
{
$this->storage = $storage;
}
public function current(): ?User
{
return Auth::user();
}
/**
* {@inheritdoc}
*/
public function find(int $id): (Authenticatable&UserContract)|null
{
return User::where('id', $id)->first();
}
public function get(int $id): UserContract
{
$user = $this->find($id);
if ($user === null) {
throw new ModelNotFoundException();
}
return $user;
}
public function findByUuid(string $uuid): ?User
{
/** @var User|null */
return User::where('uuid', User::toOptimized($uuid))->first();
}
public function search(string $query, ?User $excludeUser, Partner $partner): Collection
{
/** @var Builder $search */
$search = User::query()
->where('status', User::STATUS_ACTIVE)
->whereHas('team', static function (Builder $query) use ($partner): void {
// Never allow partners to search for other partner users.
if (! $partner->isDefaultPartner()) {
$query->where('teams.partner_id', $partner->id);
}
});
// If a user to exclude was passed to the repository, we will exclude their User
// ID from the list. Typically we don't want to show the current user in the
// search results and only want to display the other users from the query.
if ($excludeUser !== null) {
$search->where('id', '<>', $excludeUser->getId());
}
/** @var Collection<User> */
return $search
->where(static function (Builder $search) use ($query) {
$search
->where('email', 'like', $query)
->orWhere('name', 'like', $query);
})
->get();
}
public function findOneBy(array $criteria): ?User
{
$query = User::query();
foreach ($criteria as $attribute => $value) {
$query->where($attribute, $value);
}
/** @var User $result */
$result = $query->first();
return $result;
}
public function findInaccessibleDeactivatedUserUuids(User $user): Collection
{
return $this->storage->remember(
static function () use ($user): Collection {
if ($user->hasRole(User::MANAGER_ROLES)) {
return Collection::make();
}
$team = $user->getTeam();
if ($team->canViewActivitiesOfDeactivatedUsers()) {
return Collection::make();
}
return $team->users()
->where('status', '=', User::STATUS_DEACTIVATED)
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
},
Cache\Constants::deactivatedUsersIds($user),
static fn () => Carbon::now()->addMinute(),
);
}
public function findDeactivatedUserUuids(Team $team): Collection
{
return $team->users()
->where('status', '=', User::STATUS_DEACTIVATED)
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
}
public function findUserUuids(Team $team): Collection
{
return $team->users()
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
}
public function idOrUuId(int|string $userIdentifier): User
{
/** @var User */
return User::idOrUuId($userIdentifier);
}
public function findBySharedPlaylist(Playlist $playlist): Collection
{
// If 'shares' relation is already loaded
if ($playlist->relationLoaded('shares')) {
$users = Collection::make();
// Get list of users the playlist is shared to
$shares = $playlist->getShares()->unique('to_user_id');
foreach ($shares as $share) {
$users->add($share->to);
}
return $users
->where('status', '=', User::STATUS_ACTIVE)
->sortBy('name');
}
/** @var Collection<int, User> */
return $playlist->hasManyThrough(
User::class,
Share::class,
'playlist_id',
'id',
'id',
'to_user_id',
)
->active()
->orderBy('name', 'asc')
->distinct()
->get();
}
public function updateOrCreateCrmProfile(User $user, string $crmProviderId): Profile
{
/** @var Profile */
return $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $user->getTeam()->getCrmConfiguration()->getId(),
'crm_provider_id' => $crmProviderId,
]);
}
/**
* Get all Crm Fields, related to the crm configuration for the team of which the user is a member of.
* The CrmFields collection can optionally be filtered by field properties.
*
* @return Collection<Field>
*/
public function getUserCrmConfigurationFields(UserContract $user, array $filterFields = []): Collection
{
$hasManyQueryBuilder = $user->getTeam()
->getCrmConfiguration()
->fields();
if (! empty($filterFields)) {
foreach ($filterFields as $filterKey => $filterValue) {
$hasManyQueryBuilder->where($filterKey, $filterValue);
}
}
return $hasManyQueryBuilder->get();
}
/**
* @return Collection<User>
*/
public function findTeamUsersWithMostCapableRole(int $teamId, string $providerSlug): Collection
{
$roleIds = Role::whereIn('name', [
User::ROLE_ADMIN,
User::ROLE_MANAGER,
User::ROLE_RECORDER,
])
->pluck('id')
->toArray();
return User::select('users.*')
->where('users.team_id', $teamId)
->where('users.status', User::STATUS_ACTIVE)
->join('social_accounts as sa', static function (JoinClause $q) use ($providerSlug) {
return $q->on('sa.sociable_id', '=', 'users.id')
->where([
'sa.provider' => $providerSlug,
'sa.state' => SocialAccount::STATE_CONNECTED,
]);
})
->join('role_user as ru', static function (JoinClause $q) use ($roleIds) {
return $q->on('ru.user_id', '=', 'users.id')
->whereIn('ru.role_id', $roleIds);
})
->orderBy('ru.role_id', 'ASC')
->get();
}
public function getUsersWithInboxByUserIds(array $userIds): Collection
{
return User::with(['Inbox'])->find($userIds);
}
public function getCustomerApiTeamMembers(int $teamId): Collection
{
return User::select(['id', 'uuid', 'name', 'email', 'status'])
->with(['roles'])
->where('team_id', $teamId)
->addSelect([
'teamName' => Group::select(['name'])
->whereColumn('id', 'users.group_id')
->limit(1),
'teamID' => Group::selectRaw('uuid_from_bin(groups.uuid)')
->whereColumn('id', 'users.group_id')
->limit(1),
])
->addSelect([
'crmID' => Profile::select(['crm_provider_id'])
->whereColumn('user_id', 'users.id')
->whereNotNull('crm_provider_id')
->limit(1),
])
->get();
}
public function findActiveThatSyncDialersByTeamAndCrmProviderId(Team $team, string $crmProviderId): ?User
{
return User::where('team_id', $team->getId())
->active()
->shouldSyncDialers()
->whereHas('crmProfile', static function (Builder $query) use ($crmProviderId): void {
$query->where('crm_provider_id', $crmProviderId);
$query->whereNull('archived_at');
})
->first();
}
public function getFirstTeamAdmin(Team $team, string $orderBy = 'id', string $orderDirection = 'asc'): ?User
{
return $team
->users()
->active()
->admin()
->orderBy($orderBy, $orderDirection)
->first();
}
public function disableSlackFollowUp(User $user): void
{
$user->disableSlackFollowUp();
$user->save();
}
public function enableSlackFollowUp(User $user): void
{
$user->enableSlackFollowUp();
$user->save();
}
public function findActiveByRoles(Team $team, array $roles): Collection
{
/** @var Collection<int, User> */
return $team->users()
->whereHas(
relation: 'roles',
callback: static fn (Builder $query): Builder => $query->whereIn(
column: 'name',
values: $roles,
),
)
->active()
->get();
}
public function findByTeamAndEmailAddress(
Team $team,
string $email,
bool $onlyActive = false,
bool $onlyPrimary = false,
): ?User {
/** @var QueryBuilder $query */
$query = User::where('team_id', $team->getId())
->where(function ($query) use ($email, $onlyPrimary) {
$query->where('email', $email);
if (! $onlyPrimary) {
$query->orWhere('secondary_email', $email);
}
})
->orderBy('status');
if ($onlyActive) {
$query->active();
}
/** @var User|null */
return $query->first();
}
public function findByEmail(string $email): ?User
{
/** @var User|null */
return User::query()
->whereNested(
static function (QueryBuilder $query) use ($email) {
$query
->where('email', $email)
->orWhere('secondary_email', $email);
}
)
->active()
->first();
}
public function findByPhone(string $phone): ?User
{
return User::where('phone', $phone)->first();
}
public function findBySecondaryPhone(string $secondaryPhone): ?User
{
return User::where('secondary_phone', $secondaryPhone)->first();
}
public function getUserLocales(User $user): array
{
$userLanguages = [];
$user->load('languageDialects.language');
/** @var LanguageDialect $dialect */
foreach ($user->languageDialects()->get() as $dialect) {
$userLanguages[] = $dialect->getLanguageLocale();
}
return $userLanguages;
}
public function getUserLanguageLocales(User $user): array
{
$userLanguages = [];
$user->load('languageDialects.language');
/** @var LanguageDialect $dialect */
foreach ($user->languageDialects()->get() as $dialect) {
$userLanguages[] = $dialect->getLanguage()->getLocale();
}
return array_unique($userLanguages);
}
public function updateUserLocales(User $user, array $locales): void
{
$dialectIds = [];
foreach ($locales as $locale) {
[$languageLocale, $dialectLocale] = explode('_', $locale);
$dialect = LanguageDialect::whereHas('language', function ($query) use ($languageLocale) {
$query->where('locale', $languageLocale);
})
->where('locale', $dialectLocale)
->first();
if ($dialect) {
$dialectIds[] = $dialect->getId();
}
}
$user->languageDialects()->sync($dialectIds);
}
public function getUserSetting(int $userId, string $settingName): ?UserSettings
{
/** @var ?UserSettings */
return UserSettings::query()
->where('user_id', $userId)
->where('name', $settingName)
->first();
}
public function setUserSetting(int $userId, string $name, mixed $value, string $valueType): UserSettings
{
return UserSettings::updateOrCreate(
['user_id' => $userId, 'name' => $name],
['value' => (string) $value, 'value_type' => $valueType],
);
}
public function enableCrm(User $user): void
{
$user->update(['crm_required' => true]);
}
public function getByTeamAndActivitiesParticipants(Team $team, array $activityIds): Collection
{
$emails = Participant::select('email')
->distinct()
->whereIn('activity_id', $activityIds)
->pluck('email');
return $team->users()
->whereIn('email', $emails)
->active()
->whereHasPermission(PermissionEnum::CALENDAR_SYNC)
->where('sync_conference', true)
->where('team_id', $team->getId())
->get();
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
40
1
40
65
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993
SELECT * FROM users WHERE id = 25061;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 994;
SELECT * FROM crm_profiles WHERE user_id = 25061;
select * from crm_configurations where id = 834;
SELECT * FROM teams WHERE id = 882;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;
SELECT * FROM contacts where crm_configuration_id = 834;
SELECT * FROM opportunities WHERE team_id = 933
# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');
AND id IN (8482561,18352941,19042734,19232139,19445140,19472541);
SELECT * FROM opportunity_contacts
WHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 485; #
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
select crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id
where crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')
# and l.converted_at IS NOT NULL
;
# [PASSWORD_DOTS]
SELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')
and opportunity_id IS NULL
order by id desc;
SELECT * FROM teams WHERE id = 604; # 598
SELECT * FROM activities WHERE id = 74410828; # [EMAIL]
SELECT * FROM accounts WHERE id = 20068382;
SELECT * FROM accounts WHERE id = 35186038;
SELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 559 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;
select * from sidekick_settings where team_id = 781;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100
SELECT * FROM crm_layouts WHERE crm_configuration_id = 711;
SELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL
and is_internal = 0 and status = 'completed'
order by id desc;
SELECT * FROM crm_layout_entities
WHERE crm_layout_id IN (2352, 2353);
;
SELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 556 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;
SELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;
select * from contacts
where crm_configuration_id = 530
and crm_provider_id = 872252;
select * from activities where crm_configuration_id = 530
and user_id = 14343 and type like '%softphone%'
and created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);
SELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t
JOIN crm_configurations c ON t.id = c.team_id
WHERE t.status = 'active';
SELECT * FROM teams where id = 1091;
SELECT * FROM crm_configurations where team_id = 1091;
SELECT * FROM activity_providers where team_id = 1091;
SELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT * FROM teams WHERE name LIKE '%Leadventure%';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1091 and sa.provider = 'salesforce';
SELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812
SELECT * FROM teams where id = 862;
SELECT * FROM crm_configurations where team_id = 862;
SELECT * FROM activity_providers where team_id = 862;
SELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT t.id, crm.id, crm.provider, ap.* FROM teams t
join crm_configurations crm on t.id = crm.team_id
join activity_providers ap on t.id = ap.team_id
where t.status = 'active' and ap.is_enabled = 1
and crm.provider = 'hubspot'
and ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',
'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');
SELECT * FROM teams where id = 1068;
SELECT * FROM crm_configurations where team_id = 1068;
SELECT * FROM activity_providers where team_id = 1068;
SELECT * FROM activities a
where crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')
and a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'
)
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by a.id desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1068 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262
SELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
select * from crm_layouts where crm_configuration_id = 834;
select * from crm_layout_entities where crm_layout_id = 2780;
select * from crm_fields where id IN (321153,321192,321193,321194);
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1057 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8
SELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20
SELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10
SELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #
SELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;
select * from users where team_id = 51; # 7783
SELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130
select * from activity_searches where user_id = 7783;
select * from activity_search_filters where activity_search_id IN (32291, 32292);
SELECT asf.activity_search_id, asf.id, asf.value
FROM activity_search_filters asf
WHERE asf.filter = 'group_id'
AND asf.value IN (
SELECT CONCAT(
HEX(SUBSTR(uuid, 5, 4)), '-',
HEX(SUBSTR(uuid, 3, 2)), '-',
HEX(SUBSTR(uuid, 1, 2)), '-',
HEX(SUBSTR(uuid, 9, 2)), '-',
HEX(SUBSTR(uuid, 11))
)
FROM groups
WHERE deleted_at IS NOT NULL
);
SELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where provider = 'hubspot';
SELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133
SELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null
# [PASSWORD_DOTS]
select * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';
select
cp.*
# DISTINCT t.id
# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields
FROM crm_profiles cp
JOIN crm_configurations crm on crm.id = cp.crm_configuration_id
JOIN users u on u.id = cp.user_id
JOIN teams t ON t.id = crm.team_id
WHERE crm.provider = 'salesforce' and t.status = 'active'
and cp.archived_at IS NULL and u.deleted_at IS NULL
and t.id NOT IN (1093)
and t.id = 2
and cp.contact_fields IS NULL;
# and c.crm_provider_id = '003Uu00000ojD4NIAU';
SELECT * FROM users WHERE id = 26484;
SELECT * FROM crm_profiles WHERE user_id = 26484;
SELECT * FROM social_accounts WHERE sociable_id = 26484;
SELECT * FROM crm_configurations where provider = 'salesforce';
select * from users where id IN (10022, 10403);
select * from users where team_id IN (526);
select * from teams where id IN (526, 532);
select * from crm_configurations where id IN (500, 516);
select * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);
select * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 526 and sa.provider = 'salesforce';
select * from team_settings where team_id IN (526, 532);
select * from users where id IN (22824);
select * from crm_profiles where crm_configuration_id IN (1026);
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1093 and sa.provider = 'salesforce';
select * from teams where id = 1099;
select * from users where id = 29643
select * from activity_processing_states;
SELECT * FROM teams where name LIKE '%Fare%'; # 233
SELECT * FROM opportunities where crm_configuration_id = 215
# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'
;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1088 and sa.provider = 'hubspot';
SELECT * FROM teams order by updated_at DESC
SELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account
select * from crm_configurations where provider = 'pipedrive';
select * from teams where id = 957;
select * from crm_configurations where id = 957;
SELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743
SELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;
select * from users where team_id = 1; # 26726 - Gabriela Dureva
SELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific
select * from activities where user_id = 26726 order by id desc;
select * from contacts where crm_configuration_id = 1
and email IN ('[EMAIL]', '[EMAIL]'); # 2094416, 2093620
SELECT * FROM contacts WHERE id = 6284931;
SELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id
WHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;
select * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);
select * from crm_configurations where id = 1;
43801692-1aeb-32ce-acba-5b80a479701a
44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b
405975c0-b3d0-7aaa-821f-09d59cae6dd1
4caf848d-4bed-2299-b248-7788d41f9fca
49bedc3f-f196-eef3-89c3-dea6a3b4aa63
43420989-a09d-b8f8-9806-c8bbf7a02aac
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
SELECT * FROM activities WHERE id = 75461988;
SELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;
select * from contacts where id = 17900517;
select * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id
where crm.provider != 'salesforce';
select * from users where id = 21047;
SELECT * FROM crm_configurations WHERE id = 892;
SELECT * FROM teams WHERE id = 942;
select * from opportunities where team_id = 942 order by updated_at desc;
select * from contacts where team_id = 942 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 942 and sa.provider = 'hubspot';
SELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430
SELECT * FROM crm_configurations WHERE id = 1;
SELECT * FROM teams WHERE crm_id = 1;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
select id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1
SELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430
select * from teams where id = 852;
select * from groups where id = 2286;
select * from sidekick_settings where team_id = 852;
select * from default_activity_types where team_id = 852;
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1 AND u.deleted_at IS NULL
AND u.crm_required = 1
AND u.team_id = 1
ORDER BY u.team_id;
SELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (
18481
);
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1
AND u.deleted_at IS NULL
AND u.crm_required = 1
# AND u.team_id = 1
AND p.id IS NULL -- Move this condition to WHERE clause
ORDER BY u.team_id;
SELECT * FROM opportunities WHERE id = 20002609;
select * from teams where id = 1122; # Velatir, 29953 - [EMAIL]
select * from crm_configurations where id = 1060;
select * from crm_layouts where crm_configuration_id = 1060;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1122 and sa.provider = 'hubspot';
select * from opportunities where team_id = 1122 order by updated_at desc;
select * from crm_field_data where object_type = 'contact';
SELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 248 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS
SELECT * FROM users where id = 24115;
SELECT * FROM accounts where id = 4002896;
SELECT * FROM teams WHERE name LIKE '%adswerve%';
SELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN ("0069N000003GIQ9QAO","0061r000019yGP9AAM","0066900001S2KWlAAN","0066900001TDpj2AAD","0066900001b8uEwAAI","0069N000001rQi0QAE","006QF00000KD40mYAD","006QF00000LzpRJYAZ","0069N000002uomtQAA","0069N000002xlMLQAY","0066900001NV6ubAAD","0061r00001HJp45AAD","006QF00000uTlUoYAK","006QF00000v0bZqYAI");
SELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203
SELECT u.id, u.email, ac.name, a.* FROM activities a
JOIN users u ON a.user_id = u.id
JOIN accounts ac ON a.account_id = ac.id
WHERE
uuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or
uuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or
uuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;
select * from users where id = 5825;
SELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;
select * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;
19594, 862
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 862 and sa.provider = 'salesforce';
select * from automated_reports where id = 36;
select ar.frequency, r.*, ar.* from automated_report_results r
join automated_reports ar on r.report_id = ar.id
where ar.frequency != 'one_off';
select s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;
select * from nudges n where n.activity_search_id
select * from teams where created_at > '2026-03-09';
SELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;
select * from users where team_id = 1 and name like '%Lukas%'; # 7160
SELECT * FROM teams WHERE id = 575;
select * from opportunities where team_id = 575;
SELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,
select * from opportunities where team_id = 1126;
SELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,
select * from opportunities where team_id = 1125;
select * from contacts c
where c.team_id = 882;
SELECT * FROM activities WHERE id = 76822967;
SELECT * FROM crm_profiles WHERE user_id = 15440;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 555;
SELECT * FROM crm_configurations WHERE id = 555;
SELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 581 and sa.provider = 'salesforce';
SELECT * FROM automated_report_results order by id desc;
select * from features;
select * from team_features where feature_id = 40;
select * from teams where id = 556;
select * from automated_reports;
where id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , ["pdf","podcast"]
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from automated_report_results order by id desc;
SELECT * FROM automated_report_results WHERE id = 1919;
select * from automated_report_results WHERE report_id = 54;
select * from opportunities where id = 7594349;
SELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - [EMAIL]
select * from playbooks where team_id = 711; # event 226147
SELECT * FROM playbook_categories WHERE playbook_id = 5515;
SELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';
SELECT * FROM crm_fields WHERE id = 226147;
SELECT * FROM crm_field_values WHERE crm_field_id = 226147;
SELECT * FROM crm_configurations WHERE id = 692;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 711 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;
select * from leads;
select * from calendars;
SELECT
t.id AS team_id,
t.name,
LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain
FROM teams t
JOIN users u ON u.team_id = t.id
JOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'
LEFT JOIN team_domains td
ON td.team_id = t.id
AND td.deleted_at IS NULL
AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))
GROUP BY t.id, t.name, calendar_domain
ORDER BY t.name, calendar_domain;
select * from users u join calendars c on c.user_id = u.id
where u.team_id = 882;
select * from activities where id = 74049485; # team 563 crm 537
select * from activities where id = 73272382; # team 563 crm 537
select * from activities where id = 64400389; # team 563 crm 537
select * from activities where id = 58081273; # team 563 crm 537
select * from activities where id = 54520297; # team 563 crm 537
select * from participants where activity_id = 58081273;
select * from activities where crm_configuration_id = 537 and provider = 'aircall'
and account_id = 19003658 order by updated_at desc;
select * from contacts where crm_configuration_id = 537 and id = 35957759;
select * from accounts where crm_configuration_id = 537 and id = 19003658;
select * from automated_report_results where id = 1976;
select * from automated_reports where id = 583;
select * from activity_searches where id = 87714;
select * from activity_search_filters where activity_search_id = 87714;
SELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid
or uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;
SELECT * FROM crm_configurations WHERE provider = 'hubspot';
select * from rate_limits;
select * from automated_report_results where media_type = 'pdf' and status = 2
and id IN (18, 1872);
select * from automated_reports where id = 54;
SELECT * FROM users WHERE id IN (24623,29443,29613);
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from text_relays where created_at > '2026-05-01'
# and id IN (32415, 32416);
and id = 32412;
select * from users where team_id = 2 and email like '%scott%' and id = 29510;
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20891-improve-sms-text-relays, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.08843085,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20891-improve-sms-text-relays","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.38397607,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.39361703,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.40093085,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Repositories;\n\nuse Carbon\\Carbon;\nuse Illuminate\\Contracts\\Auth\\Authenticatable;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\ModelNotFoundException;\nuse Illuminate\\Database\\Query\\Builder as QueryBuilder;\nuse Illuminate\\Database\\Query\\JoinClause;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Jiminny\\Component\\Cache;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\UserRepository as UserRepositoryContract;\nuse Jiminny\\Models\\Contracts\\UserContract;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Group;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Playlist\\Share;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Models\\UserSettings;\n\nclass UserRepository implements UserRepositoryContract\n{\n private Cache\\Service\\Storage $storage;\n\n public function __construct(Cache\\Service\\Storage $storage)\n {\n $this->storage = $storage;\n }\n\n public function current(): ?User\n {\n return Auth::user();\n }\n\n /**\n * {@inheritdoc}\n */\n public function find(int $id): (Authenticatable&UserContract)|null\n {\n return User::where('id', $id)->first();\n }\n\n public function get(int $id): UserContract\n {\n $user = $this->find($id);\n\n if ($user === null) {\n throw new ModelNotFoundException();\n }\n\n return $user;\n }\n\n public function findByUuid(string $uuid): ?User\n {\n /** @var User|null */\n return User::where('uuid', User::toOptimized($uuid))->first();\n }\n\n public function search(string $query, ?User $excludeUser, Partner $partner): Collection\n {\n /** @var Builder $search */\n $search = User::query()\n ->where('status', User::STATUS_ACTIVE)\n ->whereHas('team', static function (Builder $query) use ($partner): void {\n // Never allow partners to search for other partner users.\n if (! $partner->isDefaultPartner()) {\n $query->where('teams.partner_id', $partner->id);\n }\n });\n\n // If a user to exclude was passed to the repository, we will exclude their User\n // ID from the list. Typically we don't want to show the current user in the\n // search results and only want to display the other users from the query.\n if ($excludeUser !== null) {\n $search->where('id', '<>', $excludeUser->getId());\n }\n\n /** @var Collection<User> */\n return $search\n ->where(static function (Builder $search) use ($query) {\n $search\n ->where('email', 'like', $query)\n ->orWhere('name', 'like', $query);\n })\n ->get();\n }\n\n public function findOneBy(array $criteria): ?User\n {\n $query = User::query();\n\n foreach ($criteria as $attribute => $value) {\n $query->where($attribute, $value);\n }\n\n /** @var User $result */\n $result = $query->first();\n\n return $result;\n }\n\n public function findInaccessibleDeactivatedUserUuids(User $user): Collection\n {\n return $this->storage->remember(\n static function () use ($user): Collection {\n if ($user->hasRole(User::MANAGER_ROLES)) {\n return Collection::make();\n }\n\n $team = $user->getTeam();\n\n if ($team->canViewActivitiesOfDeactivatedUsers()) {\n return Collection::make();\n }\n\n return $team->users()\n ->where('status', '=', User::STATUS_DEACTIVATED)\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n },\n Cache\\Constants::deactivatedUsersIds($user),\n static fn () => Carbon::now()->addMinute(),\n );\n }\n\n\n public function findDeactivatedUserUuids(Team $team): Collection\n {\n return $team->users()\n ->where('status', '=', User::STATUS_DEACTIVATED)\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n }\n\n public function findUserUuids(Team $team): Collection\n {\n return $team->users()\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n }\n\n public function idOrUuId(int|string $userIdentifier): User\n {\n /** @var User */\n return User::idOrUuId($userIdentifier);\n }\n\n public function findBySharedPlaylist(Playlist $playlist): Collection\n {\n // If 'shares' relation is already loaded\n if ($playlist->relationLoaded('shares')) {\n $users = Collection::make();\n\n // Get list of users the playlist is shared to\n $shares = $playlist->getShares()->unique('to_user_id');\n foreach ($shares as $share) {\n $users->add($share->to);\n }\n\n return $users\n ->where('status', '=', User::STATUS_ACTIVE)\n ->sortBy('name');\n }\n\n /** @var Collection<int, User> */\n return $playlist->hasManyThrough(\n User::class,\n Share::class,\n 'playlist_id',\n 'id',\n 'id',\n 'to_user_id',\n )\n ->active()\n ->orderBy('name', 'asc')\n ->distinct()\n ->get();\n }\n\n public function updateOrCreateCrmProfile(User $user, string $crmProviderId): Profile\n {\n /** @var Profile */\n return $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $user->getTeam()->getCrmConfiguration()->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n }\n\n /**\n * Get all Crm Fields, related to the crm configuration for the team of which the user is a member of.\n * The CrmFields collection can optionally be filtered by field properties.\n *\n * @return Collection<Field>\n */\n public function getUserCrmConfigurationFields(UserContract $user, array $filterFields = []): Collection\n {\n $hasManyQueryBuilder = $user->getTeam()\n ->getCrmConfiguration()\n ->fields();\n\n if (! empty($filterFields)) {\n foreach ($filterFields as $filterKey => $filterValue) {\n $hasManyQueryBuilder->where($filterKey, $filterValue);\n }\n }\n\n return $hasManyQueryBuilder->get();\n }\n\n /**\n * @return Collection<User>\n */\n public function findTeamUsersWithMostCapableRole(int $teamId, string $providerSlug): Collection\n {\n $roleIds = Role::whereIn('name', [\n User::ROLE_ADMIN,\n User::ROLE_MANAGER,\n User::ROLE_RECORDER,\n ])\n ->pluck('id')\n ->toArray();\n\n return User::select('users.*')\n ->where('users.team_id', $teamId)\n ->where('users.status', User::STATUS_ACTIVE)\n ->join('social_accounts as sa', static function (JoinClause $q) use ($providerSlug) {\n return $q->on('sa.sociable_id', '=', 'users.id')\n ->where([\n 'sa.provider' => $providerSlug,\n 'sa.state' => SocialAccount::STATE_CONNECTED,\n ]);\n })\n ->join('role_user as ru', static function (JoinClause $q) use ($roleIds) {\n return $q->on('ru.user_id', '=', 'users.id')\n ->whereIn('ru.role_id', $roleIds);\n })\n ->orderBy('ru.role_id', 'ASC')\n ->get();\n }\n\n public function getUsersWithInboxByUserIds(array $userIds): Collection\n {\n return User::with(['Inbox'])->find($userIds);\n }\n\n public function getCustomerApiTeamMembers(int $teamId): Collection\n {\n return User::select(['id', 'uuid', 'name', 'email', 'status'])\n ->with(['roles'])\n ->where('team_id', $teamId)\n ->addSelect([\n 'teamName' => Group::select(['name'])\n ->whereColumn('id', 'users.group_id')\n ->limit(1),\n 'teamID' => Group::selectRaw('uuid_from_bin(groups.uuid)')\n ->whereColumn('id', 'users.group_id')\n ->limit(1),\n ])\n ->addSelect([\n 'crmID' => Profile::select(['crm_provider_id'])\n ->whereColumn('user_id', 'users.id')\n ->whereNotNull('crm_provider_id')\n ->limit(1),\n ])\n ->get();\n }\n\n public function findActiveThatSyncDialersByTeamAndCrmProviderId(Team $team, string $crmProviderId): ?User\n {\n return User::where('team_id', $team->getId())\n ->active()\n ->shouldSyncDialers()\n ->whereHas('crmProfile', static function (Builder $query) use ($crmProviderId): void {\n $query->where('crm_provider_id', $crmProviderId);\n $query->whereNull('archived_at');\n })\n ->first();\n }\n\n public function getFirstTeamAdmin(Team $team, string $orderBy = 'id', string $orderDirection = 'asc'): ?User\n {\n return $team\n ->users()\n ->active()\n ->admin()\n ->orderBy($orderBy, $orderDirection)\n ->first();\n }\n\n public function disableSlackFollowUp(User $user): void\n {\n $user->disableSlackFollowUp();\n $user->save();\n }\n\n public function enableSlackFollowUp(User $user): void\n {\n $user->enableSlackFollowUp();\n $user->save();\n }\n\n public function findActiveByRoles(Team $team, array $roles): Collection\n {\n /** @var Collection<int, User> */\n return $team->users()\n ->whereHas(\n relation: 'roles',\n callback: static fn (Builder $query): Builder => $query->whereIn(\n column: 'name',\n values: $roles,\n ),\n )\n ->active()\n ->get();\n }\n\n public function findByTeamAndEmailAddress(\n Team $team,\n string $email,\n bool $onlyActive = false,\n bool $onlyPrimary = false,\n ): ?User {\n /** @var QueryBuilder $query */\n $query = User::where('team_id', $team->getId())\n ->where(function ($query) use ($email, $onlyPrimary) {\n $query->where('email', $email);\n\n if (! $onlyPrimary) {\n $query->orWhere('secondary_email', $email);\n }\n })\n ->orderBy('status');\n\n if ($onlyActive) {\n $query->active();\n }\n\n /** @var User|null */\n return $query->first();\n }\n\n public function findByEmail(string $email): ?User\n {\n /** @var User|null */\n return User::query()\n ->whereNested(\n static function (QueryBuilder $query) use ($email) {\n $query\n ->where('email', $email)\n ->orWhere('secondary_email', $email);\n }\n )\n ->active()\n ->first();\n }\n\n public function findByPhone(string $phone): ?User\n {\n return User::where('phone', $phone)->first();\n }\n\n public function findBySecondaryPhone(string $secondaryPhone): ?User\n {\n return User::where('secondary_phone', $secondaryPhone)->first();\n }\n\n public function getUserLocales(User $user): array\n {\n $userLanguages = [];\n $user->load('languageDialects.language');\n\n /** @var LanguageDialect $dialect */\n foreach ($user->languageDialects()->get() as $dialect) {\n $userLanguages[] = $dialect->getLanguageLocale();\n }\n\n return $userLanguages;\n }\n\n public function getUserLanguageLocales(User $user): array\n {\n $userLanguages = [];\n $user->load('languageDialects.language');\n\n /** @var LanguageDialect $dialect */\n foreach ($user->languageDialects()->get() as $dialect) {\n $userLanguages[] = $dialect->getLanguage()->getLocale();\n }\n\n return array_unique($userLanguages);\n }\n\n public function updateUserLocales(User $user, array $locales): void\n {\n $dialectIds = [];\n\n foreach ($locales as $locale) {\n [$languageLocale, $dialectLocale] = explode('_', $locale);\n\n $dialect = LanguageDialect::whereHas('language', function ($query) use ($languageLocale) {\n $query->where('locale', $languageLocale);\n })\n ->where('locale', $dialectLocale)\n ->first();\n\n if ($dialect) {\n $dialectIds[] = $dialect->getId();\n }\n }\n\n $user->languageDialects()->sync($dialectIds);\n }\n\n public function getUserSetting(int $userId, string $settingName): ?UserSettings\n {\n /** @var ?UserSettings */\n return UserSettings::query()\n ->where('user_id', $userId)\n ->where('name', $settingName)\n ->first();\n }\n\n public function setUserSetting(int $userId, string $name, mixed $value, string $valueType): UserSettings\n {\n return UserSettings::updateOrCreate(\n ['user_id' => $userId, 'name' => $name],\n ['value' => (string) $value, 'value_type' => $valueType],\n );\n }\n\n public function enableCrm(User $user): void\n {\n $user->update(['crm_required' => true]);\n }\n\n public function getByTeamAndActivitiesParticipants(Team $team, array $activityIds): Collection\n {\n $emails = Participant::select('email')\n ->distinct()\n ->whereIn('activity_id', $activityIds)\n ->pluck('email');\n\n return $team->users()\n ->whereIn('email', $emails)\n ->active()\n ->whereHasPermission(PermissionEnum::CALENDAR_SYNC)\n ->where('sync_conference', true)\n ->where('team_id', $team->getId())\n ->get();\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Repositories;\n\nuse Carbon\\Carbon;\nuse Illuminate\\Contracts\\Auth\\Authenticatable;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\ModelNotFoundException;\nuse Illuminate\\Database\\Query\\Builder as QueryBuilder;\nuse Illuminate\\Database\\Query\\JoinClause;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Jiminny\\Component\\Cache;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\UserRepository as UserRepositoryContract;\nuse Jiminny\\Models\\Contracts\\UserContract;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Group;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Playlist\\Share;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Models\\UserSettings;\n\nclass UserRepository implements UserRepositoryContract\n{\n private Cache\\Service\\Storage $storage;\n\n public function __construct(Cache\\Service\\Storage $storage)\n {\n $this->storage = $storage;\n }\n\n public function current(): ?User\n {\n return Auth::user();\n }\n\n /**\n * {@inheritdoc}\n */\n public function find(int $id): (Authenticatable&UserContract)|null\n {\n return User::where('id', $id)->first();\n }\n\n public function get(int $id): UserContract\n {\n $user = $this->find($id);\n\n if ($user === null) {\n throw new ModelNotFoundException();\n }\n\n return $user;\n }\n\n public function findByUuid(string $uuid): ?User\n {\n /** @var User|null */\n return User::where('uuid', User::toOptimized($uuid))->first();\n }\n\n public function search(string $query, ?User $excludeUser, Partner $partner): Collection\n {\n /** @var Builder $search */\n $search = User::query()\n ->where('status', User::STATUS_ACTIVE)\n ->whereHas('team', static function (Builder $query) use ($partner): void {\n // Never allow partners to search for other partner users.\n if (! $partner->isDefaultPartner()) {\n $query->where('teams.partner_id', $partner->id);\n }\n });\n\n // If a user to exclude was passed to the repository, we will exclude their User\n // ID from the list. Typically we don't want to show the current user in the\n // search results and only want to display the other users from the query.\n if ($excludeUser !== null) {\n $search->where('id', '<>', $excludeUser->getId());\n }\n\n /** @var Collection<User> */\n return $search\n ->where(static function (Builder $search) use ($query) {\n $search\n ->where('email', 'like', $query)\n ->orWhere('name', 'like', $query);\n })\n ->get();\n }\n\n public function findOneBy(array $criteria): ?User\n {\n $query = User::query();\n\n foreach ($criteria as $attribute => $value) {\n $query->where($attribute, $value);\n }\n\n /** @var User $result */\n $result = $query->first();\n\n return $result;\n }\n\n public function findInaccessibleDeactivatedUserUuids(User $user): Collection\n {\n return $this->storage->remember(\n static function () use ($user): Collection {\n if ($user->hasRole(User::MANAGER_ROLES)) {\n return Collection::make();\n }\n\n $team = $user->getTeam();\n\n if ($team->canViewActivitiesOfDeactivatedUsers()) {\n return Collection::make();\n }\n\n return $team->users()\n ->where('status', '=', User::STATUS_DEACTIVATED)\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n },\n Cache\\Constants::deactivatedUsersIds($user),\n static fn () => Carbon::now()->addMinute(),\n );\n }\n\n\n public function findDeactivatedUserUuids(Team $team): Collection\n {\n return $team->users()\n ->where('status', '=', User::STATUS_DEACTIVATED)\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n }\n\n public function findUserUuids(Team $team): Collection\n {\n return $team->users()\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n }\n\n public function idOrUuId(int|string $userIdentifier): User\n {\n /** @var User */\n return User::idOrUuId($userIdentifier);\n }\n\n public function findBySharedPlaylist(Playlist $playlist): Collection\n {\n // If 'shares' relation is already loaded\n if ($playlist->relationLoaded('shares')) {\n $users = Collection::make();\n\n // Get list of users the playlist is shared to\n $shares = $playlist->getShares()->unique('to_user_id');\n foreach ($shares as $share) {\n $users->add($share->to);\n }\n\n return $users\n ->where('status', '=', User::STATUS_ACTIVE)\n ->sortBy('name');\n }\n\n /** @var Collection<int, User> */\n return $playlist->hasManyThrough(\n User::class,\n Share::class,\n 'playlist_id',\n 'id',\n 'id',\n 'to_user_id',\n )\n ->active()\n ->orderBy('name', 'asc')\n ->distinct()\n ->get();\n }\n\n public function updateOrCreateCrmProfile(User $user, string $crmProviderId): Profile\n {\n /** @var Profile */\n return $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $user->getTeam()->getCrmConfiguration()->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n }\n\n /**\n * Get all Crm Fields, related to the crm configuration for the team of which the user is a member of.\n * The CrmFields collection can optionally be filtered by field properties.\n *\n * @return Collection<Field>\n */\n public function getUserCrmConfigurationFields(UserContract $user, array $filterFields = []): Collection\n {\n $hasManyQueryBuilder = $user->getTeam()\n ->getCrmConfiguration()\n ->fields();\n\n if (! empty($filterFields)) {\n foreach ($filterFields as $filterKey => $filterValue) {\n $hasManyQueryBuilder->where($filterKey, $filterValue);\n }\n }\n\n return $hasManyQueryBuilder->get();\n }\n\n /**\n * @return Collection<User>\n */\n public function findTeamUsersWithMostCapableRole(int $teamId, string $providerSlug): Collection\n {\n $roleIds = Role::whereIn('name', [\n User::ROLE_ADMIN,\n User::ROLE_MANAGER,\n User::ROLE_RECORDER,\n ])\n ->pluck('id')\n ->toArray();\n\n return User::select('users.*')\n ->where('users.team_id', $teamId)\n ->where('users.status', User::STATUS_ACTIVE)\n ->join('social_accounts as sa', static function (JoinClause $q) use ($providerSlug) {\n return $q->on('sa.sociable_id', '=', 'users.id')\n ->where([\n 'sa.provider' => $providerSlug,\n 'sa.state' => SocialAccount::STATE_CONNECTED,\n ]);\n })\n ->join('role_user as ru', static function (JoinClause $q) use ($roleIds) {\n return $q->on('ru.user_id', '=', 'users.id')\n ->whereIn('ru.role_id', $roleIds);\n })\n ->orderBy('ru.role_id', 'ASC')\n ->get();\n }\n\n public function getUsersWithInboxByUserIds(array $userIds): Collection\n {\n return User::with(['Inbox'])->find($userIds);\n }\n\n public function getCustomerApiTeamMembers(int $teamId): Collection\n {\n return User::select(['id', 'uuid', 'name', 'email', 'status'])\n ->with(['roles'])\n ->where('team_id', $teamId)\n ->addSelect([\n 'teamName' => Group::select(['name'])\n ->whereColumn('id', 'users.group_id')\n ->limit(1),\n 'teamID' => Group::selectRaw('uuid_from_bin(groups.uuid)')\n ->whereColumn('id', 'users.group_id')\n ->limit(1),\n ])\n ->addSelect([\n 'crmID' => Profile::select(['crm_provider_id'])\n ->whereColumn('user_id', 'users.id')\n ->whereNotNull('crm_provider_id')\n ->limit(1),\n ])\n ->get();\n }\n\n public function findActiveThatSyncDialersByTeamAndCrmProviderId(Team $team, string $crmProviderId): ?User\n {\n return User::where('team_id', $team->getId())\n ->active()\n ->shouldSyncDialers()\n ->whereHas('crmProfile', static function (Builder $query) use ($crmProviderId): void {\n $query->where('crm_provider_id', $crmProviderId);\n $query->whereNull('archived_at');\n })\n ->first();\n }\n\n public function getFirstTeamAdmin(Team $team, string $orderBy = 'id', string $orderDirection = 'asc'): ?User\n {\n return $team\n ->users()\n ->active()\n ->admin()\n ->orderBy($orderBy, $orderDirection)\n ->first();\n }\n\n public function disableSlackFollowUp(User $user): void\n {\n $user->disableSlackFollowUp();\n $user->save();\n }\n\n public function enableSlackFollowUp(User $user): void\n {\n $user->enableSlackFollowUp();\n $user->save();\n }\n\n public function findActiveByRoles(Team $team, array $roles): Collection\n {\n /** @var Collection<int, User> */\n return $team->users()\n ->whereHas(\n relation: 'roles',\n callback: static fn (Builder $query): Builder => $query->whereIn(\n column: 'name',\n values: $roles,\n ),\n )\n ->active()\n ->get();\n }\n\n public function findByTeamAndEmailAddress(\n Team $team,\n string $email,\n bool $onlyActive = false,\n bool $onlyPrimary = false,\n ): ?User {\n /** @var QueryBuilder $query */\n $query = User::where('team_id', $team->getId())\n ->where(function ($query) use ($email, $onlyPrimary) {\n $query->where('email', $email);\n\n if (! $onlyPrimary) {\n $query->orWhere('secondary_email', $email);\n }\n })\n ->orderBy('status');\n\n if ($onlyActive) {\n $query->active();\n }\n\n /** @var User|null */\n return $query->first();\n }\n\n public function findByEmail(string $email): ?User\n {\n /** @var User|null */\n return User::query()\n ->whereNested(\n static function (QueryBuilder $query) use ($email) {\n $query\n ->where('email', $email)\n ->orWhere('secondary_email', $email);\n }\n )\n ->active()\n ->first();\n }\n\n public function findByPhone(string $phone): ?User\n {\n return User::where('phone', $phone)->first();\n }\n\n public function findBySecondaryPhone(string $secondaryPhone): ?User\n {\n return User::where('secondary_phone', $secondaryPhone)->first();\n }\n\n public function getUserLocales(User $user): array\n {\n $userLanguages = [];\n $user->load('languageDialects.language');\n\n /** @var LanguageDialect $dialect */\n foreach ($user->languageDialects()->get() as $dialect) {\n $userLanguages[] = $dialect->getLanguageLocale();\n }\n\n return $userLanguages;\n }\n\n public function getUserLanguageLocales(User $user): array\n {\n $userLanguages = [];\n $user->load('languageDialects.language');\n\n /** @var LanguageDialect $dialect */\n foreach ($user->languageDialects()->get() as $dialect) {\n $userLanguages[] = $dialect->getLanguage()->getLocale();\n }\n\n return array_unique($userLanguages);\n }\n\n public function updateUserLocales(User $user, array $locales): void\n {\n $dialectIds = [];\n\n foreach ($locales as $locale) {\n [$languageLocale, $dialectLocale] = explode('_', $locale);\n\n $dialect = LanguageDialect::whereHas('language', function ($query) use ($languageLocale) {\n $query->where('locale', $languageLocale);\n })\n ->where('locale', $dialectLocale)\n ->first();\n\n if ($dialect) {\n $dialectIds[] = $dialect->getId();\n }\n }\n\n $user->languageDialects()->sync($dialectIds);\n }\n\n public function getUserSetting(int $userId, string $settingName): ?UserSettings\n {\n /** @var ?UserSettings */\n return UserSettings::query()\n ->where('user_id', $userId)\n ->where('name', $settingName)\n ->first();\n }\n\n public function setUserSetting(int $userId, string $name, mixed $value, string $valueType): UserSettings\n {\n return UserSettings::updateOrCreate(\n ['user_id' => $userId, 'name' => $name],\n ['value' => (string) $value, 'value_type' => $valueType],\n );\n }\n\n public function enableCrm(User $user): void\n {\n $user->update(['crm_required' => true]);\n }\n\n public function getByTeamAndActivitiesParticipants(Team $team, array $activityIds): Collection\n {\n $emails = Participant::select('email')\n ->distinct()\n ->whereIn('activity_id', $activityIds)\n ->pluck('email');\n\n return $team->users()\n ->whereIn('email', $emails)\n ->active()\n ->whereHasPermission(PermissionEnum::CALENDAR_SYNC)\n ->where('sync_conference', true)\n ->where('team_id', $team->getId())\n ->get();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.40957448,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.41821808,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"bounds":{"left":0.42918882,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"bounds":{"left":0.43783244,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"bounds":{"left":0.44647607,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"bounds":{"left":0.4574468,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"bounds":{"left":0.46841756,"top":0.09896249,"width":0.024268618,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"bounds":{"left":0.4950133,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"bounds":{"left":0.50598407,"top":0.09896249,"width":0.029587766,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.7084442,"top":0.09896249,"width":0.02825798,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"40","depth":4,"bounds":{"left":0.67785907,"top":0.123703115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.69015956,"top":0.123703115,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"40","depth":4,"bounds":{"left":0.6994681,"top":0.123703115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"65","depth":4,"bounds":{"left":0.7117686,"top":0.123703115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.7237367,"top":0.12210695,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.73105055,"top":0.12210695,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993\nSELECT * FROM users WHERE id = 25061;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 994;\nSELECT * FROM crm_profiles WHERE user_id = 25061;\n\nselect * from crm_configurations where id = 834;\nSELECT * FROM teams WHERE id = 882;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;\n\nSELECT * FROM contacts where crm_configuration_id = 834;\nSELECT * FROM opportunities WHERE team_id = 933\n# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');\nAND id IN (8482561,18352941,19042734,19232139,19445140,19472541);\nSELECT * FROM opportunity_contacts\nWHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; #\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nselect crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id\nwhere crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')\n# and l.converted_at IS NOT NULL\n;\n\n# ********************************************************************\nSELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')\nand opportunity_id IS NULL\norder by id desc;\n\nSELECT * FROM teams WHERE id = 604; # 598\nSELECT * FROM activities WHERE id = 74410828; # chelseaw@allvoices.co\nSELECT * FROM accounts WHERE id = 20068382;\nSELECT * FROM accounts WHERE id = 35186038;\n\nSELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 559 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;\nselect * from sidekick_settings where team_id = 781;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 711;\nSELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL\nand is_internal = 0 and status = 'completed'\norder by id desc;\n\nSELECT * FROM crm_layout_entities\nWHERE crm_layout_id IN (2352, 2353);\n;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;\nSELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;\nselect * from contacts\nwhere crm_configuration_id = 530\nand crm_provider_id = 872252;\n\nselect * from activities where crm_configuration_id = 530\nand user_id = 14343 and type like '%softphone%'\nand created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);\n\n\nSELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t\nJOIN crm_configurations c ON t.id = c.team_id\nWHERE t.status = 'active';\n\nSELECT * FROM teams where id = 1091;\nSELECT * FROM crm_configurations where team_id = 1091;\nSELECT * FROM activity_providers where team_id = 1091;\nSELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT * FROM teams WHERE name LIKE '%Leadventure%';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1091 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812\nSELECT * FROM teams where id = 862;\nSELECT * FROM crm_configurations where team_id = 862;\nSELECT * FROM activity_providers where team_id = 862;\nSELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT t.id, crm.id, crm.provider, ap.* FROM teams t\njoin crm_configurations crm on t.id = crm.team_id\njoin activity_providers ap on t.id = ap.team_id\nwhere t.status = 'active' and ap.is_enabled = 1\nand crm.provider = 'hubspot'\nand ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',\n 'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');\n\nSELECT * FROM teams where id = 1068;\nSELECT * FROM crm_configurations where team_id = 1068;\nSELECT * FROM activity_providers where team_id = 1068;\n\nSELECT * FROM activities a\nwhere crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')\nand a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'\n )\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by a.id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1068 and sa.provider = 'hubspot';\n\n# ********************************************************************\n# ********************************************************************\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262\nSELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\nselect * from crm_layouts where crm_configuration_id = 834;\nselect * from crm_layout_entities where crm_layout_id = 2780;\nselect * from crm_fields where id IN (321153,321192,321193,321194);\n\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1057 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8\n\nSELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20\n\nSELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10\n\nSELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #\n\nSELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;\nselect * from users where team_id = 51; # 7783\nSELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130\nselect * from activity_searches where user_id = 7783;\nselect * from activity_search_filters where activity_search_id IN (32291, 32292);\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th\n# ********************************************************************\nSELECT * FROM crm_configurations where provider = 'hubspot';\nSELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133\nSELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null\n# ********************************************************************\n\nselect * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';\nselect\n cp.*\n# DISTINCT t.id\n# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields\nFROM crm_profiles cp\nJOIN crm_configurations crm on crm.id = cp.crm_configuration_id\nJOIN users u on u.id = cp.user_id\nJOIN teams t ON t.id = crm.team_id\nWHERE crm.provider = 'salesforce' and t.status = 'active'\n and cp.archived_at IS NULL and u.deleted_at IS NULL\n and t.id NOT IN (1093)\n and t.id = 2\n and cp.contact_fields IS NULL;\n# and c.crm_provider_id = '003Uu00000ojD4NIAU';\n\nSELECT * FROM users WHERE id = 26484;\nSELECT * FROM crm_profiles WHERE user_id = 26484;\nSELECT * FROM social_accounts WHERE sociable_id = 26484;\nSELECT * FROM crm_configurations where provider = 'salesforce';\nselect * from users where id IN (10022, 10403);\nselect * from users where team_id IN (526);\nselect * from teams where id IN (526, 532);\nselect * from crm_configurations where id IN (500, 516);\nselect * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);\nselect * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 526 and sa.provider = 'salesforce';\nselect * from team_settings where team_id IN (526, 532);\n\nselect * from users where id IN (22824);\nselect * from crm_profiles where crm_configuration_id IN (1026);\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1093 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1099;\nselect * from users where id = 29643\n\nselect * from activity_processing_states;\n\nSELECT * FROM teams where name LIKE '%Fare%'; # 233\nSELECT * FROM opportunities where crm_configuration_id = 215\n# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'\n;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1088 and sa.provider = 'hubspot';\n\nSELECT * FROM teams order by updated_at DESC\nSELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account\n\nselect * from crm_configurations where provider = 'pipedrive';\n\nselect * from teams where id = 957;\nselect * from crm_configurations where id = 957;\n\nSELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743\nSELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;\n\nselect * from users where team_id = 1; # 26726 - Gabriela Dureva\nSELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific\nselect * from activities where user_id = 26726 order by id desc;\nselect * from contacts where crm_configuration_id = 1\nand email IN ('charlotte.ward@prolific.com', 'frankie.bryant@prolific.com'); # 2094416, 2093620\nSELECT * FROM contacts WHERE id = 6284931;\n\nSELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id\nWHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;\n\nselect * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);\nselect * from crm_configurations where id = 1;\n\n43801692-1aeb-32ce-acba-5b80a479701a\n44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b\n405975c0-b3d0-7aaa-821f-09d59cae6dd1\n4caf848d-4bed-2299-b248-7788d41f9fca\n49bedc3f-f196-eef3-89c3-dea6a3b4aa63\n43420989-a09d-b8f8-9806-c8bbf7a02aac\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nSELECT * FROM activities WHERE id = 75461988;\n\nSELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;\n\nselect * from contacts where id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nselect * from users where id = 21047;\nSELECT * FROM crm_configurations WHERE id = 892;\nSELECT * FROM teams WHERE id = 942;\nselect * from opportunities where team_id = 942 order by updated_at desc;\nselect * from contacts where team_id = 942 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 942 and sa.provider = 'hubspot';\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430\nSELECT * FROM crm_configurations WHERE id = 1;\nSELECT * FROM teams WHERE crm_id = 1;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1\nSELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430\n\nselect * from teams where id = 852;\nselect * from groups where id = 2286;\nselect * from sidekick_settings where team_id = 852;\nselect * from default_activity_types where team_id = 852;\n\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1 AND u.deleted_at IS NULL\nAND u.crm_required = 1\nAND u.team_id = 1\nORDER BY u.team_id;\n\nSELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (\n18481\n );\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1\n AND u.deleted_at IS NULL\n AND u.crm_required = 1\n# AND u.team_id = 1\n AND p.id IS NULL -- Move this condition to WHERE clause\nORDER BY u.team_id;\n\nSELECT * FROM opportunities WHERE id = 20002609;\nselect * from teams where id = 1122; # Velatir, 29953 - christian@velatir.com\nselect * from crm_configurations where id = 1060;\nselect * from crm_layouts where crm_configuration_id = 1060;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1122 and sa.provider = 'hubspot';\nselect * from opportunities where team_id = 1122 order by updated_at desc;\n\nselect * from crm_field_data where object_type = 'contact';\n\nSELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 248 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS\nSELECT * FROM users where id = 24115;\nSELECT * FROM accounts where id = 4002896;\nSELECT * FROM teams WHERE name LIKE '%adswerve%';\nSELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN (\"0069N000003GIQ9QAO\",\"0061r000019yGP9AAM\",\"0066900001S2KWlAAN\",\"0066900001TDpj2AAD\",\"0066900001b8uEwAAI\",\"0069N000001rQi0QAE\",\"006QF00000KD40mYAD\",\"006QF00000LzpRJYAZ\",\"0069N000002uomtQAA\",\"0069N000002xlMLQAY\",\"0066900001NV6ubAAD\",\"0061r00001HJp45AAD\",\"006QF00000uTlUoYAK\",\"006QF00000v0bZqYAI\");\nSELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203\n\nSELECT u.id, u.email, ac.name, a.* FROM activities a\nJOIN users u ON a.user_id = u.id\nJOIN accounts ac ON a.account_id = ac.id\nWHERE\nuuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or\nuuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or\nuuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;\n\nselect * from users where id = 5825;\nSELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;\n\nselect * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;\n19594, 862\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 862 and sa.provider = 'salesforce';\n\nselect * from automated_reports where id = 36;\nselect ar.frequency, r.*, ar.* from automated_report_results r\njoin automated_reports ar on r.report_id = ar.id\nwhere ar.frequency != 'one_off';\n\nselect s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;\nselect * from nudges n where n.activity_search_id\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;\n\nselect * from users where team_id = 1 and name like '%Lukas%'; # 7160\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\nSELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,\nselect * from opportunities where team_id = 1126;\nSELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,\nselect * from opportunities where team_id = 1125;\nselect * from contacts c\nwhere c.team_id = 882;\n\nSELECT * FROM activities WHERE id = 76822967;\nSELECT * FROM crm_profiles WHERE user_id = 15440;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 555;\nSELECT * FROM crm_configurations WHERE id = 555;\nSELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 581 and sa.provider = 'salesforce';\n\nSELECT * FROM automated_report_results order by id desc;\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556;\n\nselect * from automated_reports;\nwhere id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , [\"pdf\",\"podcast\"]\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\nselect * from automated_report_results order by id desc;\nSELECT * FROM automated_report_results WHERE id = 1919;\n\nselect * from automated_report_results WHERE report_id = 54;\n\nselect * from opportunities where id = 7594349;\n\nSELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - jiminnyintegration@lesmills.com\nselect * from playbooks where team_id = 711; # event 226147\nSELECT * FROM playbook_categories WHERE playbook_id = 5515;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';\nSELECT * FROM crm_fields WHERE id = 226147;\nSELECT * FROM crm_field_values WHERE crm_field_id = 226147;\n\nSELECT * FROM crm_configurations WHERE id = 692;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 711 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;\n\nselect * from leads;\n\nselect * from calendars;\n\nSELECT\n t.id AS team_id,\n t.name,\n LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain\nFROM teams t\nJOIN users u ON u.team_id = t.id\nJOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'\nLEFT JOIN team_domains td\n ON td.team_id = t.id\n AND td.deleted_at IS NULL\n AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))\nGROUP BY t.id, t.name, calendar_domain\nORDER BY t.name, calendar_domain;\n\nselect * from users u join calendars c on c.user_id = u.id\nwhere u.team_id = 882;\n\n\nselect * from activities where id = 74049485; # team 563 crm 537\nselect * from activities where id = 73272382; # team 563 crm 537\nselect * from activities where id = 64400389; # team 563 crm 537\nselect * from activities where id = 58081273; # team 563 crm 537\nselect * from activities where id = 54520297; # team 563 crm 537\nselect * from participants where activity_id = 58081273;\n\nselect * from activities where crm_configuration_id = 537 and provider = 'aircall'\nand account_id = 19003658 order by updated_at desc;\n\nselect * from contacts where crm_configuration_id = 537 and id = 35957759;\nselect * from accounts where crm_configuration_id = 537 and id = 19003658;\n\nselect * from automated_report_results where id = 1976;\nselect * from automated_reports where id = 583;\nselect * from activity_searches where id = 87714;\nselect * from activity_search_filters where activity_search_id = 87714;\n\nSELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid\nor uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot';\nselect * from rate_limits;\n\nselect * from automated_report_results where media_type = 'pdf' and status = 2\nand id IN (18, 1872);\nselect * from automated_reports where id = 54;\nSELECT * FROM users WHERE id IN (24623,29443,29613);\n\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\n\nselect * from text_relays where created_at > '2026-05-01'\n# and id IN (32415, 32416);\nand id = 32412;\n\nselect * from users where team_id = 2 and email like '%scott%' and id = 29510;","depth":4,"on_screen":true,"value":"SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993\nSELECT * FROM users WHERE id = 25061;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 994;\nSELECT * FROM crm_profiles WHERE user_id = 25061;\n\nselect * from crm_configurations where id = 834;\nSELECT * FROM teams WHERE id = 882;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;\n\nSELECT * FROM contacts where crm_configuration_id = 834;\nSELECT * FROM opportunities WHERE team_id = 933\n# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');\nAND id IN (8482561,18352941,19042734,19232139,19445140,19472541);\nSELECT * FROM opportunity_contacts\nWHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; #\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nselect crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id\nwhere crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')\n# and l.converted_at IS NOT NULL\n;\n\n# ********************************************************************\nSELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')\nand opportunity_id IS NULL\norder by id desc;\n\nSELECT * FROM teams WHERE id = 604; # 598\nSELECT * FROM activities WHERE id = 74410828; # chelseaw@allvoices.co\nSELECT * FROM accounts WHERE id = 20068382;\nSELECT * FROM accounts WHERE id = 35186038;\n\nSELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 559 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;\nselect * from sidekick_settings where team_id = 781;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 711;\nSELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL\nand is_internal = 0 and status = 'completed'\norder by id desc;\n\nSELECT * FROM crm_layout_entities\nWHERE crm_layout_id IN (2352, 2353);\n;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;\nSELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;\nselect * from contacts\nwhere crm_configuration_id = 530\nand crm_provider_id = 872252;\n\nselect * from activities where crm_configuration_id = 530\nand user_id = 14343 and type like '%softphone%'\nand created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);\n\n\nSELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t\nJOIN crm_configurations c ON t.id = c.team_id\nWHERE t.status = 'active';\n\nSELECT * FROM teams where id = 1091;\nSELECT * FROM crm_configurations where team_id = 1091;\nSELECT * FROM activity_providers where team_id = 1091;\nSELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT * FROM teams WHERE name LIKE '%Leadventure%';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1091 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812\nSELECT * FROM teams where id = 862;\nSELECT * FROM crm_configurations where team_id = 862;\nSELECT * FROM activity_providers where team_id = 862;\nSELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT t.id, crm.id, crm.provider, ap.* FROM teams t\njoin crm_configurations crm on t.id = crm.team_id\njoin activity_providers ap on t.id = ap.team_id\nwhere t.status = 'active' and ap.is_enabled = 1\nand crm.provider = 'hubspot'\nand ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',\n 'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');\n\nSELECT * FROM teams where id = 1068;\nSELECT * FROM crm_configurations where team_id = 1068;\nSELECT * FROM activity_providers where team_id = 1068;\n\nSELECT * FROM activities a\nwhere crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')\nand a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'\n )\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by a.id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1068 and sa.provider = 'hubspot';\n\n# ********************************************************************\n# ********************************************************************\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262\nSELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\nselect * from crm_layouts where crm_configuration_id = 834;\nselect * from crm_layout_entities where crm_layout_id = 2780;\nselect * from crm_fields where id IN (321153,321192,321193,321194);\n\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1057 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8\n\nSELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20\n\nSELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10\n\nSELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #\n\nSELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;\nselect * from users where team_id = 51; # 7783\nSELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130\nselect * from activity_searches where user_id = 7783;\nselect * from activity_search_filters where activity_search_id IN (32291, 32292);\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th\n# ********************************************************************\nSELECT * FROM crm_configurations where provider = 'hubspot';\nSELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133\nSELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null\n# ********************************************************************\n\nselect * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';\nselect\n cp.*\n# DISTINCT t.id\n# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields\nFROM crm_profiles cp\nJOIN crm_configurations crm on crm.id = cp.crm_configuration_id\nJOIN users u on u.id = cp.user_id\nJOIN teams t ON t.id = crm.team_id\nWHERE crm.provider = 'salesforce' and t.status = 'active'\n and cp.archived_at IS NULL and u.deleted_at IS NULL\n and t.id NOT IN (1093)\n and t.id = 2\n and cp.contact_fields IS NULL;\n# and c.crm_provider_id = '003Uu00000ojD4NIAU';\n\nSELECT * FROM users WHERE id = 26484;\nSELECT * FROM crm_profiles WHERE user_id = 26484;\nSELECT * FROM social_accounts WHERE sociable_id = 26484;\nSELECT * FROM crm_configurations where provider = 'salesforce';\nselect * from users where id IN (10022, 10403);\nselect * from users where team_id IN (526);\nselect * from teams where id IN (526, 532);\nselect * from crm_configurations where id IN (500, 516);\nselect * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);\nselect * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 526 and sa.provider = 'salesforce';\nselect * from team_settings where team_id IN (526, 532);\n\nselect * from users where id IN (22824);\nselect * from crm_profiles where crm_configuration_id IN (1026);\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1093 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1099;\nselect * from users where id = 29643\n\nselect * from activity_processing_states;\n\nSELECT * FROM teams where name LIKE '%Fare%'; # 233\nSELECT * FROM opportunities where crm_configuration_id = 215\n# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'\n;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1088 and sa.provider = 'hubspot';\n\nSELECT * FROM teams order by updated_at DESC\nSELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account\n\nselect * from crm_configurations where provider = 'pipedrive';\n\nselect * from teams where id = 957;\nselect * from crm_configurations where id = 957;\n\nSELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743\nSELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;\n\nselect * from users where team_id = 1; # 26726 - Gabriela Dureva\nSELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific\nselect * from activities where user_id = 26726 order by id desc;\nselect * from contacts where crm_configuration_id = 1\nand email IN ('charlotte.ward@prolific.com', 'frankie.bryant@prolific.com'); # 2094416, 2093620\nSELECT * FROM contacts WHERE id = 6284931;\n\nSELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id\nWHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;\n\nselect * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);\nselect * from crm_configurations where id = 1;\n\n43801692-1aeb-32ce-acba-5b80a479701a\n44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b\n405975c0-b3d0-7aaa-821f-09d59cae6dd1\n4caf848d-4bed-2299-b248-7788d41f9fca\n49bedc3f-f196-eef3-89c3-dea6a3b4aa63\n43420989-a09d-b8f8-9806-c8bbf7a02aac\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nSELECT * FROM activities WHERE id = 75461988;\n\nSELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;\n\nselect * from contacts where id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nselect * from users where id = 21047;\nSELECT * FROM crm_configurations WHERE id = 892;\nSELECT * FROM teams WHERE id = 942;\nselect * from opportunities where team_id = 942 order by updated_at desc;\nselect * from contacts where team_id = 942 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 942 and sa.provider = 'hubspot';\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430\nSELECT * FROM crm_configurations WHERE id = 1;\nSELECT * FROM teams WHERE crm_id = 1;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1\nSELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430\n\nselect * from teams where id = 852;\nselect * from groups where id = 2286;\nselect * from sidekick_settings where team_id = 852;\nselect * from default_activity_types where team_id = 852;\n\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1 AND u.deleted_at IS NULL\nAND u.crm_required = 1\nAND u.team_id = 1\nORDER BY u.team_id;\n\nSELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (\n18481\n );\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1\n AND u.deleted_at IS NULL\n AND u.crm_required = 1\n# AND u.team_id = 1\n AND p.id IS NULL -- Move this condition to WHERE clause\nORDER BY u.team_id;\n\nSELECT * FROM opportunities WHERE id = 20002609;\nselect * from teams where id = 1122; # Velatir, 29953 - christian@velatir.com\nselect * from crm_configurations where id = 1060;\nselect * from crm_layouts where crm_configuration_id = 1060;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1122 and sa.provider = 'hubspot';\nselect * from opportunities where team_id = 1122 order by updated_at desc;\n\nselect * from crm_field_data where object_type = 'contact';\n\nSELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 248 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS\nSELECT * FROM users where id = 24115;\nSELECT * FROM accounts where id = 4002896;\nSELECT * FROM teams WHERE name LIKE '%adswerve%';\nSELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN (\"0069N000003GIQ9QAO\",\"0061r000019yGP9AAM\",\"0066900001S2KWlAAN\",\"0066900001TDpj2AAD\",\"0066900001b8uEwAAI\",\"0069N000001rQi0QAE\",\"006QF00000KD40mYAD\",\"006QF00000LzpRJYAZ\",\"0069N000002uomtQAA\",\"0069N000002xlMLQAY\",\"0066900001NV6ubAAD\",\"0061r00001HJp45AAD\",\"006QF00000uTlUoYAK\",\"006QF00000v0bZqYAI\");\nSELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203\n\nSELECT u.id, u.email, ac.name, a.* FROM activities a\nJOIN users u ON a.user_id = u.id\nJOIN accounts ac ON a.account_id = ac.id\nWHERE\nuuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or\nuuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or\nuuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;\n\nselect * from users where id = 5825;\nSELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;\n\nselect * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;\n19594, 862\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 862 and sa.provider = 'salesforce';\n\nselect * from automated_reports where id = 36;\nselect ar.frequency, r.*, ar.* from automated_report_results r\njoin automated_reports ar on r.report_id = ar.id\nwhere ar.frequency != 'one_off';\n\nselect s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;\nselect * from nudges n where n.activity_search_id\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;\n\nselect * from users where team_id = 1 and name like '%Lukas%'; # 7160\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\nSELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,\nselect * from opportunities where team_id = 1126;\nSELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,\nselect * from opportunities where team_id = 1125;\nselect * from contacts c\nwhere c.team_id = 882;\n\nSELECT * FROM activities WHERE id = 76822967;\nSELECT * FROM crm_profiles WHERE user_id = 15440;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 555;\nSELECT * FROM crm_configurations WHERE id = 555;\nSELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 581 and sa.provider = 'salesforce';\n\nSELECT * FROM automated_report_results order by id desc;\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556;\n\nselect * from automated_reports;\nwhere id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , [\"pdf\",\"podcast\"]\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\nselect * from automated_report_results order by id desc;\nSELECT * FROM automated_report_results WHERE id = 1919;\n\nselect * from automated_report_results WHERE report_id = 54;\n\nselect * from opportunities where id = 7594349;\n\nSELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - jiminnyintegration@lesmills.com\nselect * from playbooks where team_id = 711; # event 226147\nSELECT * FROM playbook_categories WHERE playbook_id = 5515;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';\nSELECT * FROM crm_fields WHERE id = 226147;\nSELECT * FROM crm_field_values WHERE crm_field_id = 226147;\n\nSELECT * FROM crm_configurations WHERE id = 692;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 711 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;\n\nselect * from leads;\n\nselect * from calendars;\n\nSELECT\n t.id AS team_id,\n t.name,\n LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain\nFROM teams t\nJOIN users u ON u.team_id = t.id\nJOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'\nLEFT JOIN team_domains td\n ON td.team_id = t.id\n AND td.deleted_at IS NULL\n AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))\nGROUP BY t.id, t.name, calendar_domain\nORDER BY t.name, calendar_domain;\n\nselect * from users u join calendars c on c.user_id = u.id\nwhere u.team_id = 882;\n\n\nselect * from activities where id = 74049485; # team 563 crm 537\nselect * from activities where id = 73272382; # team 563 crm 537\nselect * from activities where id = 64400389; # team 563 crm 537\nselect * from activities where id = 58081273; # team 563 crm 537\nselect * from activities where id = 54520297; # team 563 crm 537\nselect * from participants where activity_id = 58081273;\n\nselect * from activities where crm_configuration_id = 537 and provider = 'aircall'\nand account_id = 19003658 order by updated_at desc;\n\nselect * from contacts where crm_configuration_id = 537 and id = 35957759;\nselect * from accounts where crm_configuration_id = 537 and id = 19003658;\n\nselect * from automated_report_results where id = 1976;\nselect * from automated_reports where id = 583;\nselect * from activity_searches where id = 87714;\nselect * from activity_search_filters where activity_search_id = 87714;\n\nSELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid\nor uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot';\nselect * from rate_limits;\n\nselect * from automated_report_results where media_type = 'pdf' and status = 2\nand id IN (18, 1872);\nselect * from automated_reports where id = 54;\nSELECT * FROM users WHERE id IN (24623,29443,29613);\n\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\n\nselect * from text_relays where created_at > '2026-05-01'\n# and id IN (32415, 32416);\nand id = 32412;\n\nselect * from users where team_id = 2 and email like '%scott%' and id = 29510;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6077548721905289573
|
2218600144103159373
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20891-improve-sms-text Project: faVsco.js, menu
JY-20891-improve-sms-text-relays, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Repositories;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Jiminny\Component\Cache;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\UserRepository as UserRepositoryContract;
use Jiminny\Models\Contracts\UserContract;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Group;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Participant;
use Jiminny\Models\Partner;
use Jiminny\Models\Playlist;
use Jiminny\Models\Playlist\Share;
use Jiminny\Models\Role;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Jiminny\Models\UserSettings;
class UserRepository implements UserRepositoryContract
{
private Cache\Service\Storage $storage;
public function __construct(Cache\Service\Storage $storage)
{
$this->storage = $storage;
}
public function current(): ?User
{
return Auth::user();
}
/**
* {@inheritdoc}
*/
public function find(int $id): (Authenticatable&UserContract)|null
{
return User::where('id', $id)->first();
}
public function get(int $id): UserContract
{
$user = $this->find($id);
if ($user === null) {
throw new ModelNotFoundException();
}
return $user;
}
public function findByUuid(string $uuid): ?User
{
/** @var User|null */
return User::where('uuid', User::toOptimized($uuid))->first();
}
public function search(string $query, ?User $excludeUser, Partner $partner): Collection
{
/** @var Builder $search */
$search = User::query()
->where('status', User::STATUS_ACTIVE)
->whereHas('team', static function (Builder $query) use ($partner): void {
// Never allow partners to search for other partner users.
if (! $partner->isDefaultPartner()) {
$query->where('teams.partner_id', $partner->id);
}
});
// If a user to exclude was passed to the repository, we will exclude their User
// ID from the list. Typically we don't want to show the current user in the
// search results and only want to display the other users from the query.
if ($excludeUser !== null) {
$search->where('id', '<>', $excludeUser->getId());
}
/** @var Collection<User> */
return $search
->where(static function (Builder $search) use ($query) {
$search
->where('email', 'like', $query)
->orWhere('name', 'like', $query);
})
->get();
}
public function findOneBy(array $criteria): ?User
{
$query = User::query();
foreach ($criteria as $attribute => $value) {
$query->where($attribute, $value);
}
/** @var User $result */
$result = $query->first();
return $result;
}
public function findInaccessibleDeactivatedUserUuids(User $user): Collection
{
return $this->storage->remember(
static function () use ($user): Collection {
if ($user->hasRole(User::MANAGER_ROLES)) {
return Collection::make();
}
$team = $user->getTeam();
if ($team->canViewActivitiesOfDeactivatedUsers()) {
return Collection::make();
}
return $team->users()
->where('status', '=', User::STATUS_DEACTIVATED)
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
},
Cache\Constants::deactivatedUsersIds($user),
static fn () => Carbon::now()->addMinute(),
);
}
public function findDeactivatedUserUuids(Team $team): Collection
{
return $team->users()
->where('status', '=', User::STATUS_DEACTIVATED)
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
}
public function findUserUuids(Team $team): Collection
{
return $team->users()
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
}
public function idOrUuId(int|string $userIdentifier): User
{
/** @var User */
return User::idOrUuId($userIdentifier);
}
public function findBySharedPlaylist(Playlist $playlist): Collection
{
// If 'shares' relation is already loaded
if ($playlist->relationLoaded('shares')) {
$users = Collection::make();
// Get list of users the playlist is shared to
$shares = $playlist->getShares()->unique('to_user_id');
foreach ($shares as $share) {
$users->add($share->to);
}
return $users
->where('status', '=', User::STATUS_ACTIVE)
->sortBy('name');
}
/** @var Collection<int, User> */
return $playlist->hasManyThrough(
User::class,
Share::class,
'playlist_id',
'id',
'id',
'to_user_id',
)
->active()
->orderBy('name', 'asc')
->distinct()
->get();
}
public function updateOrCreateCrmProfile(User $user, string $crmProviderId): Profile
{
/** @var Profile */
return $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $user->getTeam()->getCrmConfiguration()->getId(),
'crm_provider_id' => $crmProviderId,
]);
}
/**
* Get all Crm Fields, related to the crm configuration for the team of which the user is a member of.
* The CrmFields collection can optionally be filtered by field properties.
*
* @return Collection<Field>
*/
public function getUserCrmConfigurationFields(UserContract $user, array $filterFields = []): Collection
{
$hasManyQueryBuilder = $user->getTeam()
->getCrmConfiguration()
->fields();
if (! empty($filterFields)) {
foreach ($filterFields as $filterKey => $filterValue) {
$hasManyQueryBuilder->where($filterKey, $filterValue);
}
}
return $hasManyQueryBuilder->get();
}
/**
* @return Collection<User>
*/
public function findTeamUsersWithMostCapableRole(int $teamId, string $providerSlug): Collection
{
$roleIds = Role::whereIn('name', [
User::ROLE_ADMIN,
User::ROLE_MANAGER,
User::ROLE_RECORDER,
])
->pluck('id')
->toArray();
return User::select('users.*')
->where('users.team_id', $teamId)
->where('users.status', User::STATUS_ACTIVE)
->join('social_accounts as sa', static function (JoinClause $q) use ($providerSlug) {
return $q->on('sa.sociable_id', '=', 'users.id')
->where([
'sa.provider' => $providerSlug,
'sa.state' => SocialAccount::STATE_CONNECTED,
]);
})
->join('role_user as ru', static function (JoinClause $q) use ($roleIds) {
return $q->on('ru.user_id', '=', 'users.id')
->whereIn('ru.role_id', $roleIds);
})
->orderBy('ru.role_id', 'ASC')
->get();
}
public function getUsersWithInboxByUserIds(array $userIds): Collection
{
return User::with(['Inbox'])->find($userIds);
}
public function getCustomerApiTeamMembers(int $teamId): Collection
{
return User::select(['id', 'uuid', 'name', 'email', 'status'])
->with(['roles'])
->where('team_id', $teamId)
->addSelect([
'teamName' => Group::select(['name'])
->whereColumn('id', 'users.group_id')
->limit(1),
'teamID' => Group::selectRaw('uuid_from_bin(groups.uuid)')
->whereColumn('id', 'users.group_id')
->limit(1),
])
->addSelect([
'crmID' => Profile::select(['crm_provider_id'])
->whereColumn('user_id', 'users.id')
->whereNotNull('crm_provider_id')
->limit(1),
])
->get();
}
public function findActiveThatSyncDialersByTeamAndCrmProviderId(Team $team, string $crmProviderId): ?User
{
return User::where('team_id', $team->getId())
->active()
->shouldSyncDialers()
->whereHas('crmProfile', static function (Builder $query) use ($crmProviderId): void {
$query->where('crm_provider_id', $crmProviderId);
$query->whereNull('archived_at');
})
->first();
}
public function getFirstTeamAdmin(Team $team, string $orderBy = 'id', string $orderDirection = 'asc'): ?User
{
return $team
->users()
->active()
->admin()
->orderBy($orderBy, $orderDirection)
->first();
}
public function disableSlackFollowUp(User $user): void
{
$user->disableSlackFollowUp();
$user->save();
}
public function enableSlackFollowUp(User $user): void
{
$user->enableSlackFollowUp();
$user->save();
}
public function findActiveByRoles(Team $team, array $roles): Collection
{
/** @var Collection<int, User> */
return $team->users()
->whereHas(
relation: 'roles',
callback: static fn (Builder $query): Builder => $query->whereIn(
column: 'name',
values: $roles,
),
)
->active()
->get();
}
public function findByTeamAndEmailAddress(
Team $team,
string $email,
bool $onlyActive = false,
bool $onlyPrimary = false,
): ?User {
/** @var QueryBuilder $query */
$query = User::where('team_id', $team->getId())
->where(function ($query) use ($email, $onlyPrimary) {
$query->where('email', $email);
if (! $onlyPrimary) {
$query->orWhere('secondary_email', $email);
}
})
->orderBy('status');
if ($onlyActive) {
$query->active();
}
/** @var User|null */
return $query->first();
}
public function findByEmail(string $email): ?User
{
/** @var User|null */
return User::query()
->whereNested(
static function (QueryBuilder $query) use ($email) {
$query
->where('email', $email)
->orWhere('secondary_email', $email);
}
)
->active()
->first();
}
public function findByPhone(string $phone): ?User
{
return User::where('phone', $phone)->first();
}
public function findBySecondaryPhone(string $secondaryPhone): ?User
{
return User::where('secondary_phone', $secondaryPhone)->first();
}
public function getUserLocales(User $user): array
{
$userLanguages = [];
$user->load('languageDialects.language');
/** @var LanguageDialect $dialect */
foreach ($user->languageDialects()->get() as $dialect) {
$userLanguages[] = $dialect->getLanguageLocale();
}
return $userLanguages;
}
public function getUserLanguageLocales(User $user): array
{
$userLanguages = [];
$user->load('languageDialects.language');
/** @var LanguageDialect $dialect */
foreach ($user->languageDialects()->get() as $dialect) {
$userLanguages[] = $dialect->getLanguage()->getLocale();
}
return array_unique($userLanguages);
}
public function updateUserLocales(User $user, array $locales): void
{
$dialectIds = [];
foreach ($locales as $locale) {
[$languageLocale, $dialectLocale] = explode('_', $locale);
$dialect = LanguageDialect::whereHas('language', function ($query) use ($languageLocale) {
$query->where('locale', $languageLocale);
})
->where('locale', $dialectLocale)
->first();
if ($dialect) {
$dialectIds[] = $dialect->getId();
}
}
$user->languageDialects()->sync($dialectIds);
}
public function getUserSetting(int $userId, string $settingName): ?UserSettings
{
/** @var ?UserSettings */
return UserSettings::query()
->where('user_id', $userId)
->where('name', $settingName)
->first();
}
public function setUserSetting(int $userId, string $name, mixed $value, string $valueType): UserSettings
{
return UserSettings::updateOrCreate(
['user_id' => $userId, 'name' => $name],
['value' => (string) $value, 'value_type' => $valueType],
);
}
public function enableCrm(User $user): void
{
$user->update(['crm_required' => true]);
}
public function getByTeamAndActivitiesParticipants(Team $team, array $activityIds): Collection
{
$emails = Participant::select('email')
->distinct()
->whereIn('activity_id', $activityIds)
->pluck('email');
return $team->users()
->whereIn('email', $emails)
->active()
->whereHasPermission(PermissionEnum::CALENDAR_SYNC)
->where('sync_conference', true)
->where('team_id', $team->getId())
->get();
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
40
1
40
65
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993
SELECT * FROM users WHERE id = 25061;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 994;
SELECT * FROM crm_profiles WHERE user_id = 25061;
select * from crm_configurations where id = 834;
SELECT * FROM teams WHERE id = 882;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;
SELECT * FROM contacts where crm_configuration_id = 834;
SELECT * FROM opportunities WHERE team_id = 933
# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');
AND id IN (8482561,18352941,19042734,19232139,19445140,19472541);
SELECT * FROM opportunity_contacts
WHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 485; #
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
select crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id
where crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')
# and l.converted_at IS NOT NULL
;
# [PASSWORD_DOTS]
SELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')
and opportunity_id IS NULL
order by id desc;
SELECT * FROM teams WHERE id = 604; # 598
SELECT * FROM activities WHERE id = 74410828; # [EMAIL]
SELECT * FROM accounts WHERE id = 20068382;
SELECT * FROM accounts WHERE id = 35186038;
SELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 559 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;
select * from sidekick_settings where team_id = 781;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100
SELECT * FROM crm_layouts WHERE crm_configuration_id = 711;
SELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL
and is_internal = 0 and status = 'completed'
order by id desc;
SELECT * FROM crm_layout_entities
WHERE crm_layout_id IN (2352, 2353);
;
SELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 556 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;
SELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;
select * from contacts
where crm_configuration_id = 530
and crm_provider_id = 872252;
select * from activities where crm_configuration_id = 530
and user_id = 14343 and type like '%softphone%'
and created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);
SELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t
JOIN crm_configurations c ON t.id = c.team_id
WHERE t.status = 'active';
SELECT * FROM teams where id = 1091;
SELECT * FROM crm_configurations where team_id = 1091;
SELECT * FROM activity_providers where team_id = 1091;
SELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT * FROM teams WHERE name LIKE '%Leadventure%';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1091 and sa.provider = 'salesforce';
SELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812
SELECT * FROM teams where id = 862;
SELECT * FROM crm_configurations where team_id = 862;
SELECT * FROM activity_providers where team_id = 862;
SELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT t.id, crm.id, crm.provider, ap.* FROM teams t
join crm_configurations crm on t.id = crm.team_id
join activity_providers ap on t.id = ap.team_id
where t.status = 'active' and ap.is_enabled = 1
and crm.provider = 'hubspot'
and ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',
'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');
SELECT * FROM teams where id = 1068;
SELECT * FROM crm_configurations where team_id = 1068;
SELECT * FROM activity_providers where team_id = 1068;
SELECT * FROM activities a
where crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')
and a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'
)
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by a.id desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1068 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262
SELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
select * from crm_layouts where crm_configuration_id = 834;
select * from crm_layout_entities where crm_layout_id = 2780;
select * from crm_fields where id IN (321153,321192,321193,321194);
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1057 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8
SELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20
SELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10
SELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #
SELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;
select * from users where team_id = 51; # 7783
SELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130
select * from activity_searches where user_id = 7783;
select * from activity_search_filters where activity_search_id IN (32291, 32292);
SELECT asf.activity_search_id, asf.id, asf.value
FROM activity_search_filters asf
WHERE asf.filter = 'group_id'
AND asf.value IN (
SELECT CONCAT(
HEX(SUBSTR(uuid, 5, 4)), '-',
HEX(SUBSTR(uuid, 3, 2)), '-',
HEX(SUBSTR(uuid, 1, 2)), '-',
HEX(SUBSTR(uuid, 9, 2)), '-',
HEX(SUBSTR(uuid, 11))
)
FROM groups
WHERE deleted_at IS NOT NULL
);
SELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where provider = 'hubspot';
SELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133
SELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null
# [PASSWORD_DOTS]
select * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';
select
cp.*
# DISTINCT t.id
# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields
FROM crm_profiles cp
JOIN crm_configurations crm on crm.id = cp.crm_configuration_id
JOIN users u on u.id = cp.user_id
JOIN teams t ON t.id = crm.team_id
WHERE crm.provider = 'salesforce' and t.status = 'active'
and cp.archived_at IS NULL and u.deleted_at IS NULL
and t.id NOT IN (1093)
and t.id = 2
and cp.contact_fields IS NULL;
# and c.crm_provider_id = '003Uu00000ojD4NIAU';
SELECT * FROM users WHERE id = 26484;
SELECT * FROM crm_profiles WHERE user_id = 26484;
SELECT * FROM social_accounts WHERE sociable_id = 26484;
SELECT * FROM crm_configurations where provider = 'salesforce';
select * from users where id IN (10022, 10403);
select * from users where team_id IN (526);
select * from teams where id IN (526, 532);
select * from crm_configurations where id IN (500, 516);
select * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);
select * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 526 and sa.provider = 'salesforce';
select * from team_settings where team_id IN (526, 532);
select * from users where id IN (22824);
select * from crm_profiles where crm_configuration_id IN (1026);
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1093 and sa.provider = 'salesforce';
select * from teams where id = 1099;
select * from users where id = 29643
select * from activity_processing_states;
SELECT * FROM teams where name LIKE '%Fare%'; # 233
SELECT * FROM opportunities where crm_configuration_id = 215
# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'
;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1088 and sa.provider = 'hubspot';
SELECT * FROM teams order by updated_at DESC
SELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account
select * from crm_configurations where provider = 'pipedrive';
select * from teams where id = 957;
select * from crm_configurations where id = 957;
SELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743
SELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;
select * from users where team_id = 1; # 26726 - Gabriela Dureva
SELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific
select * from activities where user_id = 26726 order by id desc;
select * from contacts where crm_configuration_id = 1
and email IN ('[EMAIL]', '[EMAIL]'); # 2094416, 2093620
SELECT * FROM contacts WHERE id = 6284931;
SELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id
WHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;
select * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);
select * from crm_configurations where id = 1;
43801692-1aeb-32ce-acba-5b80a479701a
44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b
405975c0-b3d0-7aaa-821f-09d59cae6dd1
4caf848d-4bed-2299-b248-7788d41f9fca
49bedc3f-f196-eef3-89c3-dea6a3b4aa63
43420989-a09d-b8f8-9806-c8bbf7a02aac
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
SELECT * FROM activities WHERE id = 75461988;
SELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;
select * from contacts where id = 17900517;
select * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id
where crm.provider != 'salesforce';
select * from users where id = 21047;
SELECT * FROM crm_configurations WHERE id = 892;
SELECT * FROM teams WHERE id = 942;
select * from opportunities where team_id = 942 order by updated_at desc;
select * from contacts where team_id = 942 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 942 and sa.provider = 'hubspot';
SELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430
SELECT * FROM crm_configurations WHERE id = 1;
SELECT * FROM teams WHERE crm_id = 1;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
select id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1
SELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430
select * from teams where id = 852;
select * from groups where id = 2286;
select * from sidekick_settings where team_id = 852;
select * from default_activity_types where team_id = 852;
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1 AND u.deleted_at IS NULL
AND u.crm_required = 1
AND u.team_id = 1
ORDER BY u.team_id;
SELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (
18481
);
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1
AND u.deleted_at IS NULL
AND u.crm_required = 1
# AND u.team_id = 1
AND p.id IS NULL -- Move this condition to WHERE clause
ORDER BY u.team_id;
SELECT * FROM opportunities WHERE id = 20002609;
select * from teams where id = 1122; # Velatir, 29953 - [EMAIL]
select * from crm_configurations where id = 1060;
select * from crm_layouts where crm_configuration_id = 1060;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1122 and sa.provider = 'hubspot';
select * from opportunities where team_id = 1122 order by updated_at desc;
select * from crm_field_data where object_type = 'contact';
SELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 248 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS
SELECT * FROM users where id = 24115;
SELECT * FROM accounts where id = 4002896;
SELECT * FROM teams WHERE name LIKE '%adswerve%';
SELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN ("0069N000003GIQ9QAO","0061r000019yGP9AAM","0066900001S2KWlAAN","0066900001TDpj2AAD","0066900001b8uEwAAI","0069N000001rQi0QAE","006QF00000KD40mYAD","006QF00000LzpRJYAZ","0069N000002uomtQAA","0069N000002xlMLQAY","0066900001NV6ubAAD","0061r00001HJp45AAD","006QF00000uTlUoYAK","006QF00000v0bZqYAI");
SELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203
SELECT u.id, u.email, ac.name, a.* FROM activities a
JOIN users u ON a.user_id = u.id
JOIN accounts ac ON a.account_id = ac.id
WHERE
uuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or
uuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or
uuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;
select * from users where id = 5825;
SELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;
select * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;
19594, 862
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 862 and sa.provider = 'salesforce';
select * from automated_reports where id = 36;
select ar.frequency, r.*, ar.* from automated_report_results r
join automated_reports ar on r.report_id = ar.id
where ar.frequency != 'one_off';
select s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;
select * from nudges n where n.activity_search_id
select * from teams where created_at > '2026-03-09';
SELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;
select * from users where team_id = 1 and name like '%Lukas%'; # 7160
SELECT * FROM teams WHERE id = 575;
select * from opportunities where team_id = 575;
SELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,
select * from opportunities where team_id = 1126;
SELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,
select * from opportunities where team_id = 1125;
select * from contacts c
where c.team_id = 882;
SELECT * FROM activities WHERE id = 76822967;
SELECT * FROM crm_profiles WHERE user_id = 15440;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 555;
SELECT * FROM crm_configurations WHERE id = 555;
SELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 581 and sa.provider = 'salesforce';
SELECT * FROM automated_report_results order by id desc;
select * from features;
select * from team_features where feature_id = 40;
select * from teams where id = 556;
select * from automated_reports;
where id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , ["pdf","podcast"]
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from automated_report_results order by id desc;
SELECT * FROM automated_report_results WHERE id = 1919;
select * from automated_report_results WHERE report_id = 54;
select * from opportunities where id = 7594349;
SELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - [EMAIL]
select * from playbooks where team_id = 711; # event 226147
SELECT * FROM playbook_categories WHERE playbook_id = 5515;
SELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';
SELECT * FROM crm_fields WHERE id = 226147;
SELECT * FROM crm_field_values WHERE crm_field_id = 226147;
SELECT * FROM crm_configurations WHERE id = 692;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 711 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;
select * from leads;
select * from calendars;
SELECT
t.id AS team_id,
t.name,
LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain
FROM teams t
JOIN users u ON u.team_id = t.id
JOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'
LEFT JOIN team_domains td
ON td.team_id = t.id
AND td.deleted_at IS NULL
AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))
GROUP BY t.id, t.name, calendar_domain
ORDER BY t.name, calendar_domain;
select * from users u join calendars c on c.user_id = u.id
where u.team_id = 882;
select * from activities where id = 74049485; # team 563 crm 537
select * from activities where id = 73272382; # team 563 crm 537
select * from activities where id = 64400389; # team 563 crm 537
select * from activities where id = 58081273; # team 563 crm 537
select * from activities where id = 54520297; # team 563 crm 537
select * from participants where activity_id = 58081273;
select * from activities where crm_configuration_id = 537 and provider = 'aircall'
and account_id = 19003658 order by updated_at desc;
select * from contacts where crm_configuration_id = 537 and id = 35957759;
select * from accounts where crm_configuration_id = 537 and id = 19003658;
select * from automated_report_results where id = 1976;
select * from automated_reports where id = 583;
select * from activity_searches where id = 87714;
select * from activity_search_filters where activity_search_id = 87714;
SELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid
or uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;
SELECT * FROM crm_configurations WHERE provider = 'hubspot';
select * from rate_limits;
select * from automated_report_results where media_type = 'pdf' and status = 2
and id IN (18, 1872);
select * from automated_reports where id = 54;
SELECT * FROM users WHERE id IN (24623,29443,29613);
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from text_relays where created_at > '2026-05-01'
# and id IN (32415, 32416);
and id = 32412;
select * from users where team_id = 2 and email like '%scott%' and id = 29510;
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
34751
|
1299
|
23
|
2026-05-13T11:36:50.058110+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-13/1778 /Users/lukas/.screenpipe/data/data/2026-05-13/1778672210058_m1.jpg...
|
PhpStorm
|
faVsco.js – UserRepository.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20891-improve-sms-text Project: faVsco.js, menu
JY-20891-improve-sms-text-relays, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Repositories;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Jiminny\Component\Cache;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\UserRepository as UserRepositoryContract;
use Jiminny\Models\Contracts\UserContract;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Group;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Participant;
use Jiminny\Models\Partner;
use Jiminny\Models\Playlist;
use Jiminny\Models\Playlist\Share;
use Jiminny\Models\Role;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Jiminny\Models\UserSettings;
class UserRepository implements UserRepositoryContract
{
private Cache\Service\Storage $storage;
public function __construct(Cache\Service\Storage $storage)
{
$this->storage = $storage;
}
public function current(): ?User
{
return Auth::user();
}
/**
* {@inheritdoc}
*/
public function find(int $id): (Authenticatable&UserContract)|null
{
return User::where('id', $id)->first();
}
public function get(int $id): UserContract
{
$user = $this->find($id);
if ($user === null) {
throw new ModelNotFoundException();
}
return $user;
}
public function findByUuid(string $uuid): ?User
{
/** @var User|null */
return User::where('uuid', User::toOptimized($uuid))->first();
}
public function search(string $query, ?User $excludeUser, Partner $partner): Collection
{
/** @var Builder $search */
$search = User::query()
->where('status', User::STATUS_ACTIVE)
->whereHas('team', static function (Builder $query) use ($partner): void {
// Never allow partners to search for other partner users.
if (! $partner->isDefaultPartner()) {
$query->where('teams.partner_id', $partner->id);
}
});
// If a user to exclude was passed to the repository, we will exclude their User
// ID from the list. Typically we don't want to show the current user in the
// search results and only want to display the other users from the query.
if ($excludeUser !== null) {
$search->where('id', '<>', $excludeUser->getId());
}
/** @var Collection<User> */
return $search
->where(static function (Builder $search) use ($query) {
$search
->where('email', 'like', $query)
->orWhere('name', 'like', $query);
})
->get();
}
public function findOneBy(array $criteria): ?User
{
$query = User::query();
foreach ($criteria as $attribute => $value) {
$query->where($attribute, $value);
}
/** @var User $result */
$result = $query->first();
return $result;
}
public function findInaccessibleDeactivatedUserUuids(User $user): Collection
{
return $this->storage->remember(
static function () use ($user): Collection {
if ($user->hasRole(User::MANAGER_ROLES)) {
return Collection::make();
}
$team = $user->getTeam();
if ($team->canViewActivitiesOfDeactivatedUsers()) {
return Collection::make();
}
return $team->users()
->where('status', '=', User::STATUS_DEACTIVATED)
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
},
Cache\Constants::deactivatedUsersIds($user),
static fn () => Carbon::now()->addMinute(),
);
}
public function findDeactivatedUserUuids(Team $team): Collection
{
return $team->users()
->where('status', '=', User::STATUS_DEACTIVATED)
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
}
public function findUserUuids(Team $team): Collection
{
return $team->users()
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
}
public function idOrUuId(int|string $userIdentifier): User
{
/** @var User */
return User::idOrUuId($userIdentifier);
}
public function findBySharedPlaylist(Playlist $playlist): Collection
{
// If 'shares' relation is already loaded
if ($playlist->relationLoaded('shares')) {
$users = Collection::make();
// Get list of users the playlist is shared to
$shares = $playlist->getShares()->unique('to_user_id');
foreach ($shares as $share) {
$users->add($share->to);
}
return $users
->where('status', '=', User::STATUS_ACTIVE)
->sortBy('name');
}
/** @var Collection<int, User> */
return $playlist->hasManyThrough(
User::class,
Share::class,
'playlist_id',
'id',
'id',
'to_user_id',
)
->active()
->orderBy('name', 'asc')
->distinct()
->get();
}
public function updateOrCreateCrmProfile(User $user, string $crmProviderId): Profile
{
/** @var Profile */
return $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $user->getTeam()->getCrmConfiguration()->getId(),
'crm_provider_id' => $crmProviderId,
]);
}
/**
* Get all Crm Fields, related to the crm configuration for the team of which the user is a member of.
* The CrmFields collection can optionally be filtered by field properties.
*
* @return Collection<Field>
*/
public function getUserCrmConfigurationFields(UserContract $user, array $filterFields = []): Collection
{
$hasManyQueryBuilder = $user->getTeam()
->getCrmConfiguration()
->fields();
if (! empty($filterFields)) {
foreach ($filterFields as $filterKey => $filterValue) {
$hasManyQueryBuilder->where($filterKey, $filterValue);
}
}
return $hasManyQueryBuilder->get();
}
/**
* @return Collection<User>
*/
public function findTeamUsersWithMostCapableRole(int $teamId, string $providerSlug): Collection
{
$roleIds = Role::whereIn('name', [
User::ROLE_ADMIN,
User::ROLE_MANAGER,
User::ROLE_RECORDER,
])
->pluck('id')
->toArray();
return User::select('users.*')
->where('users.team_id', $teamId)
->where('users.status', User::STATUS_ACTIVE)
->join('social_accounts as sa', static function (JoinClause $q) use ($providerSlug) {
return $q->on('sa.sociable_id', '=', 'users.id')
->where([
'sa.provider' => $providerSlug,
'sa.state' => SocialAccount::STATE_CONNECTED,
]);
})
->join('role_user as ru', static function (JoinClause $q) use ($roleIds) {
return $q->on('ru.user_id', '=', 'users.id')
->whereIn('ru.role_id', $roleIds);
})
->orderBy('ru.role_id', 'ASC')
->get();
}
public function getUsersWithInboxByUserIds(array $userIds): Collection
{
return User::with(['Inbox'])->find($userIds);
}
public function getCustomerApiTeamMembers(int $teamId): Collection
{
return User::select(['id', 'uuid', 'name', 'email', 'status'])
->with(['roles'])
->where('team_id', $teamId)
->addSelect([
'teamName' => Group::select(['name'])
->whereColumn('id', 'users.group_id')
->limit(1),
'teamID' => Group::selectRaw('uuid_from_bin(groups.uuid)')
->whereColumn('id', 'users.group_id')
->limit(1),
])
->addSelect([
'crmID' => Profile::select(['crm_provider_id'])
->whereColumn('user_id', 'users.id')
->whereNotNull('crm_provider_id')
->limit(1),
])
->get();
}
public function findActiveThatSyncDialersByTeamAndCrmProviderId(Team $team, string $crmProviderId): ?User
{
return User::where('team_id', $team->getId())
->active()
->shouldSyncDialers()
->whereHas('crmProfile', static function (Builder $query) use ($crmProviderId): void {
$query->where('crm_provider_id', $crmProviderId);
$query->whereNull('archived_at');
})
->first();
}
public function getFirstTeamAdmin(Team $team, string $orderBy = 'id', string $orderDirection = 'asc'): ?User
{
return $team
->users()
->active()
->admin()
->orderBy($orderBy, $orderDirection)
->first();
}
public function disableSlackFollowUp(User $user): void
{
$user->disableSlackFollowUp();
$user->save();
}
public function enableSlackFollowUp(User $user): void
{
$user->enableSlackFollowUp();
$user->save();
}
public function findActiveByRoles(Team $team, array $roles): Collection
{
/** @var Collection<int, User> */
return $team->users()
->whereHas(
relation: 'roles',
callback: static fn (Builder $query): Builder => $query->whereIn(
column: 'name',
values: $roles,
),
)
->active()
->get();
}
public function findByTeamAndEmailAddress(
Team $team,
string $email,
bool $onlyActive = false,
bool $onlyPrimary = false,
): ?User {
/** @var QueryBuilder $query */
$query = User::where('team_id', $team->getId())
->where(function ($query) use ($email, $onlyPrimary) {
$query->where('email', $email);
if (! $onlyPrimary) {
$query->orWhere('secondary_email', $email);
}
})
->orderBy('status');
if ($onlyActive) {
$query->active();
}
/** @var User|null */
return $query->first();
}
public function findByEmail(string $email): ?User
{
/** @var User|null */
return User::query()
->whereNested(
static function (QueryBuilder $query) use ($email) {
$query
->where('email', $email)
->orWhere('secondary_email', $email);
}
)
->active()
->first();
}
public function findByPhone(string $phone): ?User
{
return User::where('phone', $phone)->first();
}
public function findBySecondaryPhone(string $secondaryPhone): ?User
{
return User::where('secondary_phone', $secondaryPhone)->first();
}
public function getUserLocales(User $user): array
{
$userLanguages = [];
$user->load('languageDialects.language');
/** @var LanguageDialect $dialect */
foreach ($user->languageDialects()->get() as $dialect) {
$userLanguages[] = $dialect->getLanguageLocale();
}
return $userLanguages;
}
public function getUserLanguageLocales(User $user): array
{
$userLanguages = [];
$user->load('languageDialects.language');
/** @var LanguageDialect $dialect */
foreach ($user->languageDialects()->get() as $dialect) {
$userLanguages[] = $dialect->getLanguage()->getLocale();
}
return array_unique($userLanguages);
}
public function updateUserLocales(User $user, array $locales): void
{
$dialectIds = [];
foreach ($locales as $locale) {
[$languageLocale, $dialectLocale] = explode('_', $locale);
$dialect = LanguageDialect::whereHas('language', function ($query) use ($languageLocale) {
$query->where('locale', $languageLocale);
})
->where('locale', $dialectLocale)
->first();
if ($dialect) {
$dialectIds[] = $dialect->getId();
}
}
$user->languageDialects()->sync($dialectIds);
}
public function getUserSetting(int $userId, string $settingName): ?UserSettings
{
/** @var ?UserSettings */
return UserSettings::query()
->where('user_id', $userId)
->where('name', $settingName)
->first();
}
public function setUserSetting(int $userId, string $name, mixed $value, string $valueType): UserSettings
{
return UserSettings::updateOrCreate(
['user_id' => $userId, 'name' => $name],
['value' => (string) $value, 'value_type' => $valueType],
);
}
public function enableCrm(User $user): void
{
$user->update(['crm_required' => true]);
}
public function getByTeamAndActivitiesParticipants(Team $team, array $activityIds): Collection
{
$emails = Participant::select('email')
->distinct()
->whereIn('activity_id', $activityIds)
->pluck('email');
return $team->users()
->whereIn('email', $emails)
->active()
->whereHasPermission(PermissionEnum::CALENDAR_SYNC)
->where('sync_conference', true)
->where('team_id', $team->getId())
->get();
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
40
1
40
65
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993
SELECT * FROM users WHERE id = 25061;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 994;
SELECT * FROM crm_profiles WHERE user_id = 25061;
select * from crm_configurations where id = 834;
SELECT * FROM teams WHERE id = 882;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;
SELECT * FROM contacts where crm_configuration_id = 834;
SELECT * FROM opportunities WHERE team_id = 933
# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');
AND id IN (8482561,18352941,19042734,19232139,19445140,19472541);
SELECT * FROM opportunity_contacts
WHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 485; #
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
select crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id
where crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')
# and l.converted_at IS NOT NULL
;
# [PASSWORD_DOTS]
SELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')
and opportunity_id IS NULL
order by id desc;
SELECT * FROM teams WHERE id = 604; # 598
SELECT * FROM activities WHERE id = 74410828; # [EMAIL]
SELECT * FROM accounts WHERE id = 20068382;
SELECT * FROM accounts WHERE id = 35186038;
SELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 559 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;
select * from sidekick_settings where team_id = 781;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100
SELECT * FROM crm_layouts WHERE crm_configuration_id = 711;
SELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL
and is_internal = 0 and status = 'completed'
order by id desc;
SELECT * FROM crm_layout_entities
WHERE crm_layout_id IN (2352, 2353);
;
SELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 556 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;
SELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;
select * from contacts
where crm_configuration_id = 530
and crm_provider_id = 872252;
select * from activities where crm_configuration_id = 530
and user_id = 14343 and type like '%softphone%'
and created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);
SELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t
JOIN crm_configurations c ON t.id = c.team_id
WHERE t.status = 'active';
SELECT * FROM teams where id = 1091;
SELECT * FROM crm_configurations where team_id = 1091;
SELECT * FROM activity_providers where team_id = 1091;
SELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT * FROM teams WHERE name LIKE '%Leadventure%';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1091 and sa.provider = 'salesforce';
SELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812
SELECT * FROM teams where id = 862;
SELECT * FROM crm_configurations where team_id = 862;
SELECT * FROM activity_providers where team_id = 862;
SELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT t.id, crm.id, crm.provider, ap.* FROM teams t
join crm_configurations crm on t.id = crm.team_id
join activity_providers ap on t.id = ap.team_id
where t.status = 'active' and ap.is_enabled = 1
and crm.provider = 'hubspot'
and ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',
'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');
SELECT * FROM teams where id = 1068;
SELECT * FROM crm_configurations where team_id = 1068;
SELECT * FROM activity_providers where team_id = 1068;
SELECT * FROM activities a
where crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')
and a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'
)
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by a.id desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1068 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262
SELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
select * from crm_layouts where crm_configuration_id = 834;
select * from crm_layout_entities where crm_layout_id = 2780;
select * from crm_fields where id IN (321153,321192,321193,321194);
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1057 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8
SELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20
SELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10
SELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #
SELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;
select * from users where team_id = 51; # 7783
SELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130
select * from activity_searches where user_id = 7783;
select * from activity_search_filters where activity_search_id IN (32291, 32292);
SELECT asf.activity_search_id, asf.id, asf.value
FROM activity_search_filters asf
WHERE asf.filter = 'group_id'
AND asf.value IN (
SELECT CONCAT(
HEX(SUBSTR(uuid, 5, 4)), '-',
HEX(SUBSTR(uuid, 3, 2)), '-',
HEX(SUBSTR(uuid, 1, 2)), '-',
HEX(SUBSTR(uuid, 9, 2)), '-',
HEX(SUBSTR(uuid, 11))
)
FROM groups
WHERE deleted_at IS NOT NULL
);
SELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where provider = 'hubspot';
SELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133
SELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null
# [PASSWORD_DOTS]
select * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';
select
cp.*
# DISTINCT t.id
# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields
FROM crm_profiles cp
JOIN crm_configurations crm on crm.id = cp.crm_configuration_id
JOIN users u on u.id = cp.user_id
JOIN teams t ON t.id = crm.team_id
WHERE crm.provider = 'salesforce' and t.status = 'active'
and cp.archived_at IS NULL and u.deleted_at IS NULL
and t.id NOT IN (1093)
and t.id = 2
and cp.contact_fields IS NULL;
# and c.crm_provider_id = '003Uu00000ojD4NIAU';
SELECT * FROM users WHERE id = 26484;
SELECT * FROM crm_profiles WHERE user_id = 26484;
SELECT * FROM social_accounts WHERE sociable_id = 26484;
SELECT * FROM crm_configurations where provider = 'salesforce';
select * from users where id IN (10022, 10403);
select * from users where team_id IN (526);
select * from teams where id IN (526, 532);
select * from crm_configurations where id IN (500, 516);
select * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);
select * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 526 and sa.provider = 'salesforce';
select * from team_settings where team_id IN (526, 532);
select * from users where id IN (22824);
select * from crm_profiles where crm_configuration_id IN (1026);
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1093 and sa.provider = 'salesforce';
select * from teams where id = 1099;
select * from users where id = 29643
select * from activity_processing_states;
SELECT * FROM teams where name LIKE '%Fare%'; # 233
SELECT * FROM opportunities where crm_configuration_id = 215
# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'
;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1088 and sa.provider = 'hubspot';
SELECT * FROM teams order by updated_at DESC
SELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account
select * from crm_configurations where provider = 'pipedrive';
select * from teams where id = 957;
select * from crm_configurations where id = 957;
SELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743
SELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;
select * from users where team_id = 1; # 26726 - Gabriela Dureva
SELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific
select * from activities where user_id = 26726 order by id desc;
select * from contacts where crm_configuration_id = 1
and email IN ('[EMAIL]', '[EMAIL]'); # 2094416, 2093620
SELECT * FROM contacts WHERE id = 6284931;
SELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id
WHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;
select * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);
select * from crm_configurations where id = 1;
43801692-1aeb-32ce-acba-5b80a479701a
44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b
405975c0-b3d0-7aaa-821f-09d59cae6dd1
4caf848d-4bed-2299-b248-7788d41f9fca
49bedc3f-f196-eef3-89c3-dea6a3b4aa63
43420989-a09d-b8f8-9806-c8bbf7a02aac
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
SELECT * FROM activities WHERE id = 75461988;
SELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;
select * from contacts where id = 17900517;
select * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id
where crm.provider != 'salesforce';
select * from users where id = 21047;
SELECT * FROM crm_configurations WHERE id = 892;
SELECT * FROM teams WHERE id = 942;
select * from opportunities where team_id = 942 order by updated_at desc;
select * from contacts where team_id = 942 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 942 and sa.provider = 'hubspot';
SELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430
SELECT * FROM crm_configurations WHERE id = 1;
SELECT * FROM teams WHERE crm_id = 1;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
select id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1
SELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430
select * from teams where id = 852;
select * from groups where id = 2286;
select * from sidekick_settings where team_id = 852;
select * from default_activity_types where team_id = 852;
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1 AND u.deleted_at IS NULL
AND u.crm_required = 1
AND u.team_id = 1
ORDER BY u.team_id;
SELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (
18481
);
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1
AND u.deleted_at IS NULL
AND u.crm_required = 1
# AND u.team_id = 1
AND p.id IS NULL -- Move this condition to WHERE clause
ORDER BY u.team_id;
SELECT * FROM opportunities WHERE id = 20002609;
select * from teams where id = 1122; # Velatir, 29953 - [EMAIL]
select * from crm_configurations where id = 1060;
select * from crm_layouts where crm_configuration_id = 1060;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1122 and sa.provider = 'hubspot';
select * from opportunities where team_id = 1122 order by updated_at desc;
select * from crm_field_data where object_type = 'contact';
SELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 248 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS
SELECT * FROM users where id = 24115;
SELECT * FROM accounts where id = 4002896;
SELECT * FROM teams WHERE name LIKE '%adswerve%';
SELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN ("0069N000003GIQ9QAO","0061r000019yGP9AAM","0066900001S2KWlAAN","0066900001TDpj2AAD","0066900001b8uEwAAI","0069N000001rQi0QAE","006QF00000KD40mYAD","006QF00000LzpRJYAZ","0069N000002uomtQAA","0069N000002xlMLQAY","0066900001NV6ubAAD","0061r00001HJp45AAD","006QF00000uTlUoYAK","006QF00000v0bZqYAI");
SELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203
SELECT u.id, u.email, ac.name, a.* FROM activities a
JOIN users u ON a.user_id = u.id
JOIN accounts ac ON a.account_id = ac.id
WHERE
uuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or
uuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or
uuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;
select * from users where id = 5825;
SELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;
select * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;
19594, 862
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 862 and sa.provider = 'salesforce';
select * from automated_reports where id = 36;
select ar.frequency, r.*, ar.* from automated_report_results r
join automated_reports ar on r.report_id = ar.id
where ar.frequency != 'one_off';
select s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;
select * from nudges n where n.activity_search_id
select * from teams where created_at > '2026-03-09';
SELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;
select * from users where team_id = 1 and name like '%Lukas%'; # 7160
SELECT * FROM teams WHERE id = 575;
select * from opportunities where team_id = 575;
SELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,
select * from opportunities where team_id = 1126;
SELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,
select * from opportunities where team_id = 1125;
select * from contacts c
where c.team_id = 882;
SELECT * FROM activities WHERE id = 76822967;
SELECT * FROM crm_profiles WHERE user_id = 15440;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 555;
SELECT * FROM crm_configurations WHERE id = 555;
SELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 581 and sa.provider = 'salesforce';
SELECT * FROM automated_report_results order by id desc;
select * from features;
select * from team_features where feature_id = 40;
select * from teams where id = 556;
select * from automated_reports;
where id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , ["pdf","podcast"]
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from automated_report_results order by id desc;
SELECT * FROM automated_report_results WHERE id = 1919;
select * from automated_report_results WHERE report_id = 54;
select * from opportunities where id = 7594349;
SELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - [EMAIL]
select * from playbooks where team_id = 711; # event 226147
SELECT * FROM playbook_categories WHERE playbook_id = 5515;
SELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';
SELECT * FROM crm_fields WHERE id = 226147;
SELECT * FROM crm_field_values WHERE crm_field_id = 226147;
SELECT * FROM crm_configurations WHERE id = 692;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 711 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;
select * from leads;
select * from calendars;
SELECT
t.id AS team_id,
t.name,
LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain
FROM teams t
JOIN users u ON u.team_id = t.id
JOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'
LEFT JOIN team_domains td
ON td.team_id = t.id
AND td.deleted_at IS NULL
AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))
GROUP BY t.id, t.name, calendar_domain
ORDER BY t.name, calendar_domain;
select * from users u join calendars c on c.user_id = u.id
where u.team_id = 882;
select * from activities where id = 74049485; # team 563 crm 537
select * from activities where id = 73272382; # team 563 crm 537
select * from activities where id = 64400389; # team 563 crm 537
select * from activities where id = 58081273; # team 563 crm 537
select * from activities where id = 54520297; # team 563 crm 537
select * from participants where activity_id = 58081273;
select * from activities where crm_configuration_id = 537 and provider = 'aircall'
and account_id = 19003658 order by updated_at desc;
select * from contacts where crm_configuration_id = 537 and id = 35957759;
select * from accounts where crm_configuration_id = 537 and id = 19003658;
select * from automated_report_results where id = 1976;
select * from automated_reports where id = 583;
select * from activity_searches where id = 87714;
select * from activity_search_filters where activity_search_id = 87714;
SELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid
or uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;
SELECT * FROM crm_configurations WHERE provider = 'hubspot';
select * from rate_limits;
select * from automated_report_results where media_type = 'pdf' and status = 2
and id IN (18, 1872);
select * from automated_reports where id = 54;
SELECT * FROM users WHERE id IN (24623,29443,29613);
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from text_relays where created_at > '2026-05-01'
# and id IN (32415, 32416);
and id = 32412;
select * from users where team_id = 2 and email like '%scott%' and id = 29510;
Project
Project
New File or Directory…...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20891-improve-sms-text-relays, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20891-improve-sms-text-relays","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Repositories;\n\nuse Carbon\\Carbon;\nuse Illuminate\\Contracts\\Auth\\Authenticatable;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\ModelNotFoundException;\nuse Illuminate\\Database\\Query\\Builder as QueryBuilder;\nuse Illuminate\\Database\\Query\\JoinClause;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Jiminny\\Component\\Cache;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\UserRepository as UserRepositoryContract;\nuse Jiminny\\Models\\Contracts\\UserContract;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Group;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Playlist\\Share;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Models\\UserSettings;\n\nclass UserRepository implements UserRepositoryContract\n{\n private Cache\\Service\\Storage $storage;\n\n public function __construct(Cache\\Service\\Storage $storage)\n {\n $this->storage = $storage;\n }\n\n public function current(): ?User\n {\n return Auth::user();\n }\n\n /**\n * {@inheritdoc}\n */\n public function find(int $id): (Authenticatable&UserContract)|null\n {\n return User::where('id', $id)->first();\n }\n\n public function get(int $id): UserContract\n {\n $user = $this->find($id);\n\n if ($user === null) {\n throw new ModelNotFoundException();\n }\n\n return $user;\n }\n\n public function findByUuid(string $uuid): ?User\n {\n /** @var User|null */\n return User::where('uuid', User::toOptimized($uuid))->first();\n }\n\n public function search(string $query, ?User $excludeUser, Partner $partner): Collection\n {\n /** @var Builder $search */\n $search = User::query()\n ->where('status', User::STATUS_ACTIVE)\n ->whereHas('team', static function (Builder $query) use ($partner): void {\n // Never allow partners to search for other partner users.\n if (! $partner->isDefaultPartner()) {\n $query->where('teams.partner_id', $partner->id);\n }\n });\n\n // If a user to exclude was passed to the repository, we will exclude their User\n // ID from the list. Typically we don't want to show the current user in the\n // search results and only want to display the other users from the query.\n if ($excludeUser !== null) {\n $search->where('id', '<>', $excludeUser->getId());\n }\n\n /** @var Collection<User> */\n return $search\n ->where(static function (Builder $search) use ($query) {\n $search\n ->where('email', 'like', $query)\n ->orWhere('name', 'like', $query);\n })\n ->get();\n }\n\n public function findOneBy(array $criteria): ?User\n {\n $query = User::query();\n\n foreach ($criteria as $attribute => $value) {\n $query->where($attribute, $value);\n }\n\n /** @var User $result */\n $result = $query->first();\n\n return $result;\n }\n\n public function findInaccessibleDeactivatedUserUuids(User $user): Collection\n {\n return $this->storage->remember(\n static function () use ($user): Collection {\n if ($user->hasRole(User::MANAGER_ROLES)) {\n return Collection::make();\n }\n\n $team = $user->getTeam();\n\n if ($team->canViewActivitiesOfDeactivatedUsers()) {\n return Collection::make();\n }\n\n return $team->users()\n ->where('status', '=', User::STATUS_DEACTIVATED)\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n },\n Cache\\Constants::deactivatedUsersIds($user),\n static fn () => Carbon::now()->addMinute(),\n );\n }\n\n\n public function findDeactivatedUserUuids(Team $team): Collection\n {\n return $team->users()\n ->where('status', '=', User::STATUS_DEACTIVATED)\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n }\n\n public function findUserUuids(Team $team): Collection\n {\n return $team->users()\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n }\n\n public function idOrUuId(int|string $userIdentifier): User\n {\n /** @var User */\n return User::idOrUuId($userIdentifier);\n }\n\n public function findBySharedPlaylist(Playlist $playlist): Collection\n {\n // If 'shares' relation is already loaded\n if ($playlist->relationLoaded('shares')) {\n $users = Collection::make();\n\n // Get list of users the playlist is shared to\n $shares = $playlist->getShares()->unique('to_user_id');\n foreach ($shares as $share) {\n $users->add($share->to);\n }\n\n return $users\n ->where('status', '=', User::STATUS_ACTIVE)\n ->sortBy('name');\n }\n\n /** @var Collection<int, User> */\n return $playlist->hasManyThrough(\n User::class,\n Share::class,\n 'playlist_id',\n 'id',\n 'id',\n 'to_user_id',\n )\n ->active()\n ->orderBy('name', 'asc')\n ->distinct()\n ->get();\n }\n\n public function updateOrCreateCrmProfile(User $user, string $crmProviderId): Profile\n {\n /** @var Profile */\n return $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $user->getTeam()->getCrmConfiguration()->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n }\n\n /**\n * Get all Crm Fields, related to the crm configuration for the team of which the user is a member of.\n * The CrmFields collection can optionally be filtered by field properties.\n *\n * @return Collection<Field>\n */\n public function getUserCrmConfigurationFields(UserContract $user, array $filterFields = []): Collection\n {\n $hasManyQueryBuilder = $user->getTeam()\n ->getCrmConfiguration()\n ->fields();\n\n if (! empty($filterFields)) {\n foreach ($filterFields as $filterKey => $filterValue) {\n $hasManyQueryBuilder->where($filterKey, $filterValue);\n }\n }\n\n return $hasManyQueryBuilder->get();\n }\n\n /**\n * @return Collection<User>\n */\n public function findTeamUsersWithMostCapableRole(int $teamId, string $providerSlug): Collection\n {\n $roleIds = Role::whereIn('name', [\n User::ROLE_ADMIN,\n User::ROLE_MANAGER,\n User::ROLE_RECORDER,\n ])\n ->pluck('id')\n ->toArray();\n\n return User::select('users.*')\n ->where('users.team_id', $teamId)\n ->where('users.status', User::STATUS_ACTIVE)\n ->join('social_accounts as sa', static function (JoinClause $q) use ($providerSlug) {\n return $q->on('sa.sociable_id', '=', 'users.id')\n ->where([\n 'sa.provider' => $providerSlug,\n 'sa.state' => SocialAccount::STATE_CONNECTED,\n ]);\n })\n ->join('role_user as ru', static function (JoinClause $q) use ($roleIds) {\n return $q->on('ru.user_id', '=', 'users.id')\n ->whereIn('ru.role_id', $roleIds);\n })\n ->orderBy('ru.role_id', 'ASC')\n ->get();\n }\n\n public function getUsersWithInboxByUserIds(array $userIds): Collection\n {\n return User::with(['Inbox'])->find($userIds);\n }\n\n public function getCustomerApiTeamMembers(int $teamId): Collection\n {\n return User::select(['id', 'uuid', 'name', 'email', 'status'])\n ->with(['roles'])\n ->where('team_id', $teamId)\n ->addSelect([\n 'teamName' => Group::select(['name'])\n ->whereColumn('id', 'users.group_id')\n ->limit(1),\n 'teamID' => Group::selectRaw('uuid_from_bin(groups.uuid)')\n ->whereColumn('id', 'users.group_id')\n ->limit(1),\n ])\n ->addSelect([\n 'crmID' => Profile::select(['crm_provider_id'])\n ->whereColumn('user_id', 'users.id')\n ->whereNotNull('crm_provider_id')\n ->limit(1),\n ])\n ->get();\n }\n\n public function findActiveThatSyncDialersByTeamAndCrmProviderId(Team $team, string $crmProviderId): ?User\n {\n return User::where('team_id', $team->getId())\n ->active()\n ->shouldSyncDialers()\n ->whereHas('crmProfile', static function (Builder $query) use ($crmProviderId): void {\n $query->where('crm_provider_id', $crmProviderId);\n $query->whereNull('archived_at');\n })\n ->first();\n }\n\n public function getFirstTeamAdmin(Team $team, string $orderBy = 'id', string $orderDirection = 'asc'): ?User\n {\n return $team\n ->users()\n ->active()\n ->admin()\n ->orderBy($orderBy, $orderDirection)\n ->first();\n }\n\n public function disableSlackFollowUp(User $user): void\n {\n $user->disableSlackFollowUp();\n $user->save();\n }\n\n public function enableSlackFollowUp(User $user): void\n {\n $user->enableSlackFollowUp();\n $user->save();\n }\n\n public function findActiveByRoles(Team $team, array $roles): Collection\n {\n /** @var Collection<int, User> */\n return $team->users()\n ->whereHas(\n relation: 'roles',\n callback: static fn (Builder $query): Builder => $query->whereIn(\n column: 'name',\n values: $roles,\n ),\n )\n ->active()\n ->get();\n }\n\n public function findByTeamAndEmailAddress(\n Team $team,\n string $email,\n bool $onlyActive = false,\n bool $onlyPrimary = false,\n ): ?User {\n /** @var QueryBuilder $query */\n $query = User::where('team_id', $team->getId())\n ->where(function ($query) use ($email, $onlyPrimary) {\n $query->where('email', $email);\n\n if (! $onlyPrimary) {\n $query->orWhere('secondary_email', $email);\n }\n })\n ->orderBy('status');\n\n if ($onlyActive) {\n $query->active();\n }\n\n /** @var User|null */\n return $query->first();\n }\n\n public function findByEmail(string $email): ?User\n {\n /** @var User|null */\n return User::query()\n ->whereNested(\n static function (QueryBuilder $query) use ($email) {\n $query\n ->where('email', $email)\n ->orWhere('secondary_email', $email);\n }\n )\n ->active()\n ->first();\n }\n\n public function findByPhone(string $phone): ?User\n {\n return User::where('phone', $phone)->first();\n }\n\n public function findBySecondaryPhone(string $secondaryPhone): ?User\n {\n return User::where('secondary_phone', $secondaryPhone)->first();\n }\n\n public function getUserLocales(User $user): array\n {\n $userLanguages = [];\n $user->load('languageDialects.language');\n\n /** @var LanguageDialect $dialect */\n foreach ($user->languageDialects()->get() as $dialect) {\n $userLanguages[] = $dialect->getLanguageLocale();\n }\n\n return $userLanguages;\n }\n\n public function getUserLanguageLocales(User $user): array\n {\n $userLanguages = [];\n $user->load('languageDialects.language');\n\n /** @var LanguageDialect $dialect */\n foreach ($user->languageDialects()->get() as $dialect) {\n $userLanguages[] = $dialect->getLanguage()->getLocale();\n }\n\n return array_unique($userLanguages);\n }\n\n public function updateUserLocales(User $user, array $locales): void\n {\n $dialectIds = [];\n\n foreach ($locales as $locale) {\n [$languageLocale, $dialectLocale] = explode('_', $locale);\n\n $dialect = LanguageDialect::whereHas('language', function ($query) use ($languageLocale) {\n $query->where('locale', $languageLocale);\n })\n ->where('locale', $dialectLocale)\n ->first();\n\n if ($dialect) {\n $dialectIds[] = $dialect->getId();\n }\n }\n\n $user->languageDialects()->sync($dialectIds);\n }\n\n public function getUserSetting(int $userId, string $settingName): ?UserSettings\n {\n /** @var ?UserSettings */\n return UserSettings::query()\n ->where('user_id', $userId)\n ->where('name', $settingName)\n ->first();\n }\n\n public function setUserSetting(int $userId, string $name, mixed $value, string $valueType): UserSettings\n {\n return UserSettings::updateOrCreate(\n ['user_id' => $userId, 'name' => $name],\n ['value' => (string) $value, 'value_type' => $valueType],\n );\n }\n\n public function enableCrm(User $user): void\n {\n $user->update(['crm_required' => true]);\n }\n\n public function getByTeamAndActivitiesParticipants(Team $team, array $activityIds): Collection\n {\n $emails = Participant::select('email')\n ->distinct()\n ->whereIn('activity_id', $activityIds)\n ->pluck('email');\n\n return $team->users()\n ->whereIn('email', $emails)\n ->active()\n ->whereHasPermission(PermissionEnum::CALENDAR_SYNC)\n ->where('sync_conference', true)\n ->where('team_id', $team->getId())\n ->get();\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Repositories;\n\nuse Carbon\\Carbon;\nuse Illuminate\\Contracts\\Auth\\Authenticatable;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\ModelNotFoundException;\nuse Illuminate\\Database\\Query\\Builder as QueryBuilder;\nuse Illuminate\\Database\\Query\\JoinClause;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Jiminny\\Component\\Cache;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\UserRepository as UserRepositoryContract;\nuse Jiminny\\Models\\Contracts\\UserContract;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Group;\nuse Jiminny\\Models\\LanguageDialect;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Playlist;\nuse Jiminny\\Models\\Playlist\\Share;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Models\\UserSettings;\n\nclass UserRepository implements UserRepositoryContract\n{\n private Cache\\Service\\Storage $storage;\n\n public function __construct(Cache\\Service\\Storage $storage)\n {\n $this->storage = $storage;\n }\n\n public function current(): ?User\n {\n return Auth::user();\n }\n\n /**\n * {@inheritdoc}\n */\n public function find(int $id): (Authenticatable&UserContract)|null\n {\n return User::where('id', $id)->first();\n }\n\n public function get(int $id): UserContract\n {\n $user = $this->find($id);\n\n if ($user === null) {\n throw new ModelNotFoundException();\n }\n\n return $user;\n }\n\n public function findByUuid(string $uuid): ?User\n {\n /** @var User|null */\n return User::where('uuid', User::toOptimized($uuid))->first();\n }\n\n public function search(string $query, ?User $excludeUser, Partner $partner): Collection\n {\n /** @var Builder $search */\n $search = User::query()\n ->where('status', User::STATUS_ACTIVE)\n ->whereHas('team', static function (Builder $query) use ($partner): void {\n // Never allow partners to search for other partner users.\n if (! $partner->isDefaultPartner()) {\n $query->where('teams.partner_id', $partner->id);\n }\n });\n\n // If a user to exclude was passed to the repository, we will exclude their User\n // ID from the list. Typically we don't want to show the current user in the\n // search results and only want to display the other users from the query.\n if ($excludeUser !== null) {\n $search->where('id', '<>', $excludeUser->getId());\n }\n\n /** @var Collection<User> */\n return $search\n ->where(static function (Builder $search) use ($query) {\n $search\n ->where('email', 'like', $query)\n ->orWhere('name', 'like', $query);\n })\n ->get();\n }\n\n public function findOneBy(array $criteria): ?User\n {\n $query = User::query();\n\n foreach ($criteria as $attribute => $value) {\n $query->where($attribute, $value);\n }\n\n /** @var User $result */\n $result = $query->first();\n\n return $result;\n }\n\n public function findInaccessibleDeactivatedUserUuids(User $user): Collection\n {\n return $this->storage->remember(\n static function () use ($user): Collection {\n if ($user->hasRole(User::MANAGER_ROLES)) {\n return Collection::make();\n }\n\n $team = $user->getTeam();\n\n if ($team->canViewActivitiesOfDeactivatedUsers()) {\n return Collection::make();\n }\n\n return $team->users()\n ->where('status', '=', User::STATUS_DEACTIVATED)\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n },\n Cache\\Constants::deactivatedUsersIds($user),\n static fn () => Carbon::now()->addMinute(),\n );\n }\n\n\n public function findDeactivatedUserUuids(Team $team): Collection\n {\n return $team->users()\n ->where('status', '=', User::STATUS_DEACTIVATED)\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n }\n\n public function findUserUuids(Team $team): Collection\n {\n return $team->users()\n ->get(['uuid'])\n ->map(static fn (User $teamMember): string => $teamMember->getUuid());\n }\n\n public function idOrUuId(int|string $userIdentifier): User\n {\n /** @var User */\n return User::idOrUuId($userIdentifier);\n }\n\n public function findBySharedPlaylist(Playlist $playlist): Collection\n {\n // If 'shares' relation is already loaded\n if ($playlist->relationLoaded('shares')) {\n $users = Collection::make();\n\n // Get list of users the playlist is shared to\n $shares = $playlist->getShares()->unique('to_user_id');\n foreach ($shares as $share) {\n $users->add($share->to);\n }\n\n return $users\n ->where('status', '=', User::STATUS_ACTIVE)\n ->sortBy('name');\n }\n\n /** @var Collection<int, User> */\n return $playlist->hasManyThrough(\n User::class,\n Share::class,\n 'playlist_id',\n 'id',\n 'id',\n 'to_user_id',\n )\n ->active()\n ->orderBy('name', 'asc')\n ->distinct()\n ->get();\n }\n\n public function updateOrCreateCrmProfile(User $user, string $crmProviderId): Profile\n {\n /** @var Profile */\n return $user->crmProfile()->updateOrCreate([\n 'crm_configuration_id' => $user->getTeam()->getCrmConfiguration()->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n }\n\n /**\n * Get all Crm Fields, related to the crm configuration for the team of which the user is a member of.\n * The CrmFields collection can optionally be filtered by field properties.\n *\n * @return Collection<Field>\n */\n public function getUserCrmConfigurationFields(UserContract $user, array $filterFields = []): Collection\n {\n $hasManyQueryBuilder = $user->getTeam()\n ->getCrmConfiguration()\n ->fields();\n\n if (! empty($filterFields)) {\n foreach ($filterFields as $filterKey => $filterValue) {\n $hasManyQueryBuilder->where($filterKey, $filterValue);\n }\n }\n\n return $hasManyQueryBuilder->get();\n }\n\n /**\n * @return Collection<User>\n */\n public function findTeamUsersWithMostCapableRole(int $teamId, string $providerSlug): Collection\n {\n $roleIds = Role::whereIn('name', [\n User::ROLE_ADMIN,\n User::ROLE_MANAGER,\n User::ROLE_RECORDER,\n ])\n ->pluck('id')\n ->toArray();\n\n return User::select('users.*')\n ->where('users.team_id', $teamId)\n ->where('users.status', User::STATUS_ACTIVE)\n ->join('social_accounts as sa', static function (JoinClause $q) use ($providerSlug) {\n return $q->on('sa.sociable_id', '=', 'users.id')\n ->where([\n 'sa.provider' => $providerSlug,\n 'sa.state' => SocialAccount::STATE_CONNECTED,\n ]);\n })\n ->join('role_user as ru', static function (JoinClause $q) use ($roleIds) {\n return $q->on('ru.user_id', '=', 'users.id')\n ->whereIn('ru.role_id', $roleIds);\n })\n ->orderBy('ru.role_id', 'ASC')\n ->get();\n }\n\n public function getUsersWithInboxByUserIds(array $userIds): Collection\n {\n return User::with(['Inbox'])->find($userIds);\n }\n\n public function getCustomerApiTeamMembers(int $teamId): Collection\n {\n return User::select(['id', 'uuid', 'name', 'email', 'status'])\n ->with(['roles'])\n ->where('team_id', $teamId)\n ->addSelect([\n 'teamName' => Group::select(['name'])\n ->whereColumn('id', 'users.group_id')\n ->limit(1),\n 'teamID' => Group::selectRaw('uuid_from_bin(groups.uuid)')\n ->whereColumn('id', 'users.group_id')\n ->limit(1),\n ])\n ->addSelect([\n 'crmID' => Profile::select(['crm_provider_id'])\n ->whereColumn('user_id', 'users.id')\n ->whereNotNull('crm_provider_id')\n ->limit(1),\n ])\n ->get();\n }\n\n public function findActiveThatSyncDialersByTeamAndCrmProviderId(Team $team, string $crmProviderId): ?User\n {\n return User::where('team_id', $team->getId())\n ->active()\n ->shouldSyncDialers()\n ->whereHas('crmProfile', static function (Builder $query) use ($crmProviderId): void {\n $query->where('crm_provider_id', $crmProviderId);\n $query->whereNull('archived_at');\n })\n ->first();\n }\n\n public function getFirstTeamAdmin(Team $team, string $orderBy = 'id', string $orderDirection = 'asc'): ?User\n {\n return $team\n ->users()\n ->active()\n ->admin()\n ->orderBy($orderBy, $orderDirection)\n ->first();\n }\n\n public function disableSlackFollowUp(User $user): void\n {\n $user->disableSlackFollowUp();\n $user->save();\n }\n\n public function enableSlackFollowUp(User $user): void\n {\n $user->enableSlackFollowUp();\n $user->save();\n }\n\n public function findActiveByRoles(Team $team, array $roles): Collection\n {\n /** @var Collection<int, User> */\n return $team->users()\n ->whereHas(\n relation: 'roles',\n callback: static fn (Builder $query): Builder => $query->whereIn(\n column: 'name',\n values: $roles,\n ),\n )\n ->active()\n ->get();\n }\n\n public function findByTeamAndEmailAddress(\n Team $team,\n string $email,\n bool $onlyActive = false,\n bool $onlyPrimary = false,\n ): ?User {\n /** @var QueryBuilder $query */\n $query = User::where('team_id', $team->getId())\n ->where(function ($query) use ($email, $onlyPrimary) {\n $query->where('email', $email);\n\n if (! $onlyPrimary) {\n $query->orWhere('secondary_email', $email);\n }\n })\n ->orderBy('status');\n\n if ($onlyActive) {\n $query->active();\n }\n\n /** @var User|null */\n return $query->first();\n }\n\n public function findByEmail(string $email): ?User\n {\n /** @var User|null */\n return User::query()\n ->whereNested(\n static function (QueryBuilder $query) use ($email) {\n $query\n ->where('email', $email)\n ->orWhere('secondary_email', $email);\n }\n )\n ->active()\n ->first();\n }\n\n public function findByPhone(string $phone): ?User\n {\n return User::where('phone', $phone)->first();\n }\n\n public function findBySecondaryPhone(string $secondaryPhone): ?User\n {\n return User::where('secondary_phone', $secondaryPhone)->first();\n }\n\n public function getUserLocales(User $user): array\n {\n $userLanguages = [];\n $user->load('languageDialects.language');\n\n /** @var LanguageDialect $dialect */\n foreach ($user->languageDialects()->get() as $dialect) {\n $userLanguages[] = $dialect->getLanguageLocale();\n }\n\n return $userLanguages;\n }\n\n public function getUserLanguageLocales(User $user): array\n {\n $userLanguages = [];\n $user->load('languageDialects.language');\n\n /** @var LanguageDialect $dialect */\n foreach ($user->languageDialects()->get() as $dialect) {\n $userLanguages[] = $dialect->getLanguage()->getLocale();\n }\n\n return array_unique($userLanguages);\n }\n\n public function updateUserLocales(User $user, array $locales): void\n {\n $dialectIds = [];\n\n foreach ($locales as $locale) {\n [$languageLocale, $dialectLocale] = explode('_', $locale);\n\n $dialect = LanguageDialect::whereHas('language', function ($query) use ($languageLocale) {\n $query->where('locale', $languageLocale);\n })\n ->where('locale', $dialectLocale)\n ->first();\n\n if ($dialect) {\n $dialectIds[] = $dialect->getId();\n }\n }\n\n $user->languageDialects()->sync($dialectIds);\n }\n\n public function getUserSetting(int $userId, string $settingName): ?UserSettings\n {\n /** @var ?UserSettings */\n return UserSettings::query()\n ->where('user_id', $userId)\n ->where('name', $settingName)\n ->first();\n }\n\n public function setUserSetting(int $userId, string $name, mixed $value, string $valueType): UserSettings\n {\n return UserSettings::updateOrCreate(\n ['user_id' => $userId, 'name' => $name],\n ['value' => (string) $value, 'value_type' => $valueType],\n );\n }\n\n public function enableCrm(User $user): void\n {\n $user->update(['crm_required' => true]);\n }\n\n public function getByTeamAndActivitiesParticipants(Team $team, array $activityIds): Collection\n {\n $emails = Participant::select('email')\n ->distinct()\n ->whereIn('activity_id', $activityIds)\n ->pluck('email');\n\n return $team->users()\n ->whereIn('email', $emails)\n ->active()\n ->whereHasPermission(PermissionEnum::CALENDAR_SYNC)\n ->where('sync_conference', true)\n ->where('team_id', $team->getId())\n ->get();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"40","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"40","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"65","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993\nSELECT * FROM users WHERE id = 25061;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 994;\nSELECT * FROM crm_profiles WHERE user_id = 25061;\n\nselect * from crm_configurations where id = 834;\nSELECT * FROM teams WHERE id = 882;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;\n\nSELECT * FROM contacts where crm_configuration_id = 834;\nSELECT * FROM opportunities WHERE team_id = 933\n# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');\nAND id IN (8482561,18352941,19042734,19232139,19445140,19472541);\nSELECT * FROM opportunity_contacts\nWHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; #\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nselect crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id\nwhere crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')\n# and l.converted_at IS NOT NULL\n;\n\n# ********************************************************************\nSELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')\nand opportunity_id IS NULL\norder by id desc;\n\nSELECT * FROM teams WHERE id = 604; # 598\nSELECT * FROM activities WHERE id = 74410828; # chelseaw@allvoices.co\nSELECT * FROM accounts WHERE id = 20068382;\nSELECT * FROM accounts WHERE id = 35186038;\n\nSELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 559 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;\nselect * from sidekick_settings where team_id = 781;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 711;\nSELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL\nand is_internal = 0 and status = 'completed'\norder by id desc;\n\nSELECT * FROM crm_layout_entities\nWHERE crm_layout_id IN (2352, 2353);\n;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;\nSELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;\nselect * from contacts\nwhere crm_configuration_id = 530\nand crm_provider_id = 872252;\n\nselect * from activities where crm_configuration_id = 530\nand user_id = 14343 and type like '%softphone%'\nand created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);\n\n\nSELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t\nJOIN crm_configurations c ON t.id = c.team_id\nWHERE t.status = 'active';\n\nSELECT * FROM teams where id = 1091;\nSELECT * FROM crm_configurations where team_id = 1091;\nSELECT * FROM activity_providers where team_id = 1091;\nSELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT * FROM teams WHERE name LIKE '%Leadventure%';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1091 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812\nSELECT * FROM teams where id = 862;\nSELECT * FROM crm_configurations where team_id = 862;\nSELECT * FROM activity_providers where team_id = 862;\nSELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT t.id, crm.id, crm.provider, ap.* FROM teams t\njoin crm_configurations crm on t.id = crm.team_id\njoin activity_providers ap on t.id = ap.team_id\nwhere t.status = 'active' and ap.is_enabled = 1\nand crm.provider = 'hubspot'\nand ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',\n 'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');\n\nSELECT * FROM teams where id = 1068;\nSELECT * FROM crm_configurations where team_id = 1068;\nSELECT * FROM activity_providers where team_id = 1068;\n\nSELECT * FROM activities a\nwhere crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')\nand a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'\n )\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by a.id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1068 and sa.provider = 'hubspot';\n\n# ********************************************************************\n# ********************************************************************\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262\nSELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\nselect * from crm_layouts where crm_configuration_id = 834;\nselect * from crm_layout_entities where crm_layout_id = 2780;\nselect * from crm_fields where id IN (321153,321192,321193,321194);\n\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1057 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8\n\nSELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20\n\nSELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10\n\nSELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #\n\nSELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;\nselect * from users where team_id = 51; # 7783\nSELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130\nselect * from activity_searches where user_id = 7783;\nselect * from activity_search_filters where activity_search_id IN (32291, 32292);\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th\n# ********************************************************************\nSELECT * FROM crm_configurations where provider = 'hubspot';\nSELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133\nSELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null\n# ********************************************************************\n\nselect * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';\nselect\n cp.*\n# DISTINCT t.id\n# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields\nFROM crm_profiles cp\nJOIN crm_configurations crm on crm.id = cp.crm_configuration_id\nJOIN users u on u.id = cp.user_id\nJOIN teams t ON t.id = crm.team_id\nWHERE crm.provider = 'salesforce' and t.status = 'active'\n and cp.archived_at IS NULL and u.deleted_at IS NULL\n and t.id NOT IN (1093)\n and t.id = 2\n and cp.contact_fields IS NULL;\n# and c.crm_provider_id = '003Uu00000ojD4NIAU';\n\nSELECT * FROM users WHERE id = 26484;\nSELECT * FROM crm_profiles WHERE user_id = 26484;\nSELECT * FROM social_accounts WHERE sociable_id = 26484;\nSELECT * FROM crm_configurations where provider = 'salesforce';\nselect * from users where id IN (10022, 10403);\nselect * from users where team_id IN (526);\nselect * from teams where id IN (526, 532);\nselect * from crm_configurations where id IN (500, 516);\nselect * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);\nselect * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 526 and sa.provider = 'salesforce';\nselect * from team_settings where team_id IN (526, 532);\n\nselect * from users where id IN (22824);\nselect * from crm_profiles where crm_configuration_id IN (1026);\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1093 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1099;\nselect * from users where id = 29643\n\nselect * from activity_processing_states;\n\nSELECT * FROM teams where name LIKE '%Fare%'; # 233\nSELECT * FROM opportunities where crm_configuration_id = 215\n# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'\n;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1088 and sa.provider = 'hubspot';\n\nSELECT * FROM teams order by updated_at DESC\nSELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account\n\nselect * from crm_configurations where provider = 'pipedrive';\n\nselect * from teams where id = 957;\nselect * from crm_configurations where id = 957;\n\nSELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743\nSELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;\n\nselect * from users where team_id = 1; # 26726 - Gabriela Dureva\nSELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific\nselect * from activities where user_id = 26726 order by id desc;\nselect * from contacts where crm_configuration_id = 1\nand email IN ('charlotte.ward@prolific.com', 'frankie.bryant@prolific.com'); # 2094416, 2093620\nSELECT * FROM contacts WHERE id = 6284931;\n\nSELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id\nWHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;\n\nselect * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);\nselect * from crm_configurations where id = 1;\n\n43801692-1aeb-32ce-acba-5b80a479701a\n44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b\n405975c0-b3d0-7aaa-821f-09d59cae6dd1\n4caf848d-4bed-2299-b248-7788d41f9fca\n49bedc3f-f196-eef3-89c3-dea6a3b4aa63\n43420989-a09d-b8f8-9806-c8bbf7a02aac\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nSELECT * FROM activities WHERE id = 75461988;\n\nSELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;\n\nselect * from contacts where id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nselect * from users where id = 21047;\nSELECT * FROM crm_configurations WHERE id = 892;\nSELECT * FROM teams WHERE id = 942;\nselect * from opportunities where team_id = 942 order by updated_at desc;\nselect * from contacts where team_id = 942 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 942 and sa.provider = 'hubspot';\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430\nSELECT * FROM crm_configurations WHERE id = 1;\nSELECT * FROM teams WHERE crm_id = 1;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1\nSELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430\n\nselect * from teams where id = 852;\nselect * from groups where id = 2286;\nselect * from sidekick_settings where team_id = 852;\nselect * from default_activity_types where team_id = 852;\n\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1 AND u.deleted_at IS NULL\nAND u.crm_required = 1\nAND u.team_id = 1\nORDER BY u.team_id;\n\nSELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (\n18481\n );\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1\n AND u.deleted_at IS NULL\n AND u.crm_required = 1\n# AND u.team_id = 1\n AND p.id IS NULL -- Move this condition to WHERE clause\nORDER BY u.team_id;\n\nSELECT * FROM opportunities WHERE id = 20002609;\nselect * from teams where id = 1122; # Velatir, 29953 - christian@velatir.com\nselect * from crm_configurations where id = 1060;\nselect * from crm_layouts where crm_configuration_id = 1060;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1122 and sa.provider = 'hubspot';\nselect * from opportunities where team_id = 1122 order by updated_at desc;\n\nselect * from crm_field_data where object_type = 'contact';\n\nSELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 248 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS\nSELECT * FROM users where id = 24115;\nSELECT * FROM accounts where id = 4002896;\nSELECT * FROM teams WHERE name LIKE '%adswerve%';\nSELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN (\"0069N000003GIQ9QAO\",\"0061r000019yGP9AAM\",\"0066900001S2KWlAAN\",\"0066900001TDpj2AAD\",\"0066900001b8uEwAAI\",\"0069N000001rQi0QAE\",\"006QF00000KD40mYAD\",\"006QF00000LzpRJYAZ\",\"0069N000002uomtQAA\",\"0069N000002xlMLQAY\",\"0066900001NV6ubAAD\",\"0061r00001HJp45AAD\",\"006QF00000uTlUoYAK\",\"006QF00000v0bZqYAI\");\nSELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203\n\nSELECT u.id, u.email, ac.name, a.* FROM activities a\nJOIN users u ON a.user_id = u.id\nJOIN accounts ac ON a.account_id = ac.id\nWHERE\nuuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or\nuuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or\nuuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;\n\nselect * from users where id = 5825;\nSELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;\n\nselect * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;\n19594, 862\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 862 and sa.provider = 'salesforce';\n\nselect * from automated_reports where id = 36;\nselect ar.frequency, r.*, ar.* from automated_report_results r\njoin automated_reports ar on r.report_id = ar.id\nwhere ar.frequency != 'one_off';\n\nselect s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;\nselect * from nudges n where n.activity_search_id\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;\n\nselect * from users where team_id = 1 and name like '%Lukas%'; # 7160\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\nSELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,\nselect * from opportunities where team_id = 1126;\nSELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,\nselect * from opportunities where team_id = 1125;\nselect * from contacts c\nwhere c.team_id = 882;\n\nSELECT * FROM activities WHERE id = 76822967;\nSELECT * FROM crm_profiles WHERE user_id = 15440;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 555;\nSELECT * FROM crm_configurations WHERE id = 555;\nSELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 581 and sa.provider = 'salesforce';\n\nSELECT * FROM automated_report_results order by id desc;\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556;\n\nselect * from automated_reports;\nwhere id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , [\"pdf\",\"podcast\"]\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\nselect * from automated_report_results order by id desc;\nSELECT * FROM automated_report_results WHERE id = 1919;\n\nselect * from automated_report_results WHERE report_id = 54;\n\nselect * from opportunities where id = 7594349;\n\nSELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - jiminnyintegration@lesmills.com\nselect * from playbooks where team_id = 711; # event 226147\nSELECT * FROM playbook_categories WHERE playbook_id = 5515;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';\nSELECT * FROM crm_fields WHERE id = 226147;\nSELECT * FROM crm_field_values WHERE crm_field_id = 226147;\n\nSELECT * FROM crm_configurations WHERE id = 692;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 711 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;\n\nselect * from leads;\n\nselect * from calendars;\n\nSELECT\n t.id AS team_id,\n t.name,\n LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain\nFROM teams t\nJOIN users u ON u.team_id = t.id\nJOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'\nLEFT JOIN team_domains td\n ON td.team_id = t.id\n AND td.deleted_at IS NULL\n AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))\nGROUP BY t.id, t.name, calendar_domain\nORDER BY t.name, calendar_domain;\n\nselect * from users u join calendars c on c.user_id = u.id\nwhere u.team_id = 882;\n\n\nselect * from activities where id = 74049485; # team 563 crm 537\nselect * from activities where id = 73272382; # team 563 crm 537\nselect * from activities where id = 64400389; # team 563 crm 537\nselect * from activities where id = 58081273; # team 563 crm 537\nselect * from activities where id = 54520297; # team 563 crm 537\nselect * from participants where activity_id = 58081273;\n\nselect * from activities where crm_configuration_id = 537 and provider = 'aircall'\nand account_id = 19003658 order by updated_at desc;\n\nselect * from contacts where crm_configuration_id = 537 and id = 35957759;\nselect * from accounts where crm_configuration_id = 537 and id = 19003658;\n\nselect * from automated_report_results where id = 1976;\nselect * from automated_reports where id = 583;\nselect * from activity_searches where id = 87714;\nselect * from activity_search_filters where activity_search_id = 87714;\n\nSELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid\nor uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot';\nselect * from rate_limits;\n\nselect * from automated_report_results where media_type = 'pdf' and status = 2\nand id IN (18, 1872);\nselect * from automated_reports where id = 54;\nSELECT * FROM users WHERE id IN (24623,29443,29613);\n\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\n\nselect * from text_relays where created_at > '2026-05-01'\n# and id IN (32415, 32416);\nand id = 32412;\n\nselect * from users where team_id = 2 and email like '%scott%' and id = 29510;","depth":4,"on_screen":true,"value":"SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993\nSELECT * FROM users WHERE id = 25061;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 994;\nSELECT * FROM crm_profiles WHERE user_id = 25061;\n\nselect * from crm_configurations where id = 834;\nSELECT * FROM teams WHERE id = 882;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;\n\nSELECT * FROM contacts where crm_configuration_id = 834;\nSELECT * FROM opportunities WHERE team_id = 933\n# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');\nAND id IN (8482561,18352941,19042734,19232139,19445140,19472541);\nSELECT * FROM opportunity_contacts\nWHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; #\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nselect crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id\nwhere crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')\n# and l.converted_at IS NOT NULL\n;\n\n# ********************************************************************\nSELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')\nand opportunity_id IS NULL\norder by id desc;\n\nSELECT * FROM teams WHERE id = 604; # 598\nSELECT * FROM activities WHERE id = 74410828; # chelseaw@allvoices.co\nSELECT * FROM accounts WHERE id = 20068382;\nSELECT * FROM accounts WHERE id = 35186038;\n\nSELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 559 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;\nselect * from sidekick_settings where team_id = 781;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 711;\nSELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL\nand is_internal = 0 and status = 'completed'\norder by id desc;\n\nSELECT * FROM crm_layout_entities\nWHERE crm_layout_id IN (2352, 2353);\n;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;\nSELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;\nselect * from contacts\nwhere crm_configuration_id = 530\nand crm_provider_id = 872252;\n\nselect * from activities where crm_configuration_id = 530\nand user_id = 14343 and type like '%softphone%'\nand created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);\n\n\nSELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t\nJOIN crm_configurations c ON t.id = c.team_id\nWHERE t.status = 'active';\n\nSELECT * FROM teams where id = 1091;\nSELECT * FROM crm_configurations where team_id = 1091;\nSELECT * FROM activity_providers where team_id = 1091;\nSELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT * FROM teams WHERE name LIKE '%Leadventure%';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1091 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812\nSELECT * FROM teams where id = 862;\nSELECT * FROM crm_configurations where team_id = 862;\nSELECT * FROM activity_providers where team_id = 862;\nSELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT t.id, crm.id, crm.provider, ap.* FROM teams t\njoin crm_configurations crm on t.id = crm.team_id\njoin activity_providers ap on t.id = ap.team_id\nwhere t.status = 'active' and ap.is_enabled = 1\nand crm.provider = 'hubspot'\nand ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',\n 'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');\n\nSELECT * FROM teams where id = 1068;\nSELECT * FROM crm_configurations where team_id = 1068;\nSELECT * FROM activity_providers where team_id = 1068;\n\nSELECT * FROM activities a\nwhere crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')\nand a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'\n )\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by a.id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1068 and sa.provider = 'hubspot';\n\n# ********************************************************************\n# ********************************************************************\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262\nSELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\nselect * from crm_layouts where crm_configuration_id = 834;\nselect * from crm_layout_entities where crm_layout_id = 2780;\nselect * from crm_fields where id IN (321153,321192,321193,321194);\n\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1057 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8\n\nSELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20\n\nSELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10\n\nSELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #\n\nSELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;\nselect * from users where team_id = 51; # 7783\nSELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130\nselect * from activity_searches where user_id = 7783;\nselect * from activity_search_filters where activity_search_id IN (32291, 32292);\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th\n# ********************************************************************\nSELECT * FROM crm_configurations where provider = 'hubspot';\nSELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133\nSELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null\n# ********************************************************************\n\nselect * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';\nselect\n cp.*\n# DISTINCT t.id\n# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields\nFROM crm_profiles cp\nJOIN crm_configurations crm on crm.id = cp.crm_configuration_id\nJOIN users u on u.id = cp.user_id\nJOIN teams t ON t.id = crm.team_id\nWHERE crm.provider = 'salesforce' and t.status = 'active'\n and cp.archived_at IS NULL and u.deleted_at IS NULL\n and t.id NOT IN (1093)\n and t.id = 2\n and cp.contact_fields IS NULL;\n# and c.crm_provider_id = '003Uu00000ojD4NIAU';\n\nSELECT * FROM users WHERE id = 26484;\nSELECT * FROM crm_profiles WHERE user_id = 26484;\nSELECT * FROM social_accounts WHERE sociable_id = 26484;\nSELECT * FROM crm_configurations where provider = 'salesforce';\nselect * from users where id IN (10022, 10403);\nselect * from users where team_id IN (526);\nselect * from teams where id IN (526, 532);\nselect * from crm_configurations where id IN (500, 516);\nselect * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);\nselect * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 526 and sa.provider = 'salesforce';\nselect * from team_settings where team_id IN (526, 532);\n\nselect * from users where id IN (22824);\nselect * from crm_profiles where crm_configuration_id IN (1026);\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1093 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1099;\nselect * from users where id = 29643\n\nselect * from activity_processing_states;\n\nSELECT * FROM teams where name LIKE '%Fare%'; # 233\nSELECT * FROM opportunities where crm_configuration_id = 215\n# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'\n;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1088 and sa.provider = 'hubspot';\n\nSELECT * FROM teams order by updated_at DESC\nSELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account\n\nselect * from crm_configurations where provider = 'pipedrive';\n\nselect * from teams where id = 957;\nselect * from crm_configurations where id = 957;\n\nSELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743\nSELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;\n\nselect * from users where team_id = 1; # 26726 - Gabriela Dureva\nSELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific\nselect * from activities where user_id = 26726 order by id desc;\nselect * from contacts where crm_configuration_id = 1\nand email IN ('charlotte.ward@prolific.com', 'frankie.bryant@prolific.com'); # 2094416, 2093620\nSELECT * FROM contacts WHERE id = 6284931;\n\nSELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id\nWHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;\n\nselect * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);\nselect * from crm_configurations where id = 1;\n\n43801692-1aeb-32ce-acba-5b80a479701a\n44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b\n405975c0-b3d0-7aaa-821f-09d59cae6dd1\n4caf848d-4bed-2299-b248-7788d41f9fca\n49bedc3f-f196-eef3-89c3-dea6a3b4aa63\n43420989-a09d-b8f8-9806-c8bbf7a02aac\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nSELECT * FROM activities WHERE id = 75461988;\n\nSELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;\n\nselect * from contacts where id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nselect * from users where id = 21047;\nSELECT * FROM crm_configurations WHERE id = 892;\nSELECT * FROM teams WHERE id = 942;\nselect * from opportunities where team_id = 942 order by updated_at desc;\nselect * from contacts where team_id = 942 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 942 and sa.provider = 'hubspot';\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430\nSELECT * FROM crm_configurations WHERE id = 1;\nSELECT * FROM teams WHERE crm_id = 1;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1\nSELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430\n\nselect * from teams where id = 852;\nselect * from groups where id = 2286;\nselect * from sidekick_settings where team_id = 852;\nselect * from default_activity_types where team_id = 852;\n\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1 AND u.deleted_at IS NULL\nAND u.crm_required = 1\nAND u.team_id = 1\nORDER BY u.team_id;\n\nSELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (\n18481\n );\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1\n AND u.deleted_at IS NULL\n AND u.crm_required = 1\n# AND u.team_id = 1\n AND p.id IS NULL -- Move this condition to WHERE clause\nORDER BY u.team_id;\n\nSELECT * FROM opportunities WHERE id = 20002609;\nselect * from teams where id = 1122; # Velatir, 29953 - christian@velatir.com\nselect * from crm_configurations where id = 1060;\nselect * from crm_layouts where crm_configuration_id = 1060;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1122 and sa.provider = 'hubspot';\nselect * from opportunities where team_id = 1122 order by updated_at desc;\n\nselect * from crm_field_data where object_type = 'contact';\n\nSELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 248 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS\nSELECT * FROM users where id = 24115;\nSELECT * FROM accounts where id = 4002896;\nSELECT * FROM teams WHERE name LIKE '%adswerve%';\nSELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN (\"0069N000003GIQ9QAO\",\"0061r000019yGP9AAM\",\"0066900001S2KWlAAN\",\"0066900001TDpj2AAD\",\"0066900001b8uEwAAI\",\"0069N000001rQi0QAE\",\"006QF00000KD40mYAD\",\"006QF00000LzpRJYAZ\",\"0069N000002uomtQAA\",\"0069N000002xlMLQAY\",\"0066900001NV6ubAAD\",\"0061r00001HJp45AAD\",\"006QF00000uTlUoYAK\",\"006QF00000v0bZqYAI\");\nSELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203\n\nSELECT u.id, u.email, ac.name, a.* FROM activities a\nJOIN users u ON a.user_id = u.id\nJOIN accounts ac ON a.account_id = ac.id\nWHERE\nuuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or\nuuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or\nuuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;\n\nselect * from users where id = 5825;\nSELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;\n\nselect * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;\n19594, 862\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 862 and sa.provider = 'salesforce';\n\nselect * from automated_reports where id = 36;\nselect ar.frequency, r.*, ar.* from automated_report_results r\njoin automated_reports ar on r.report_id = ar.id\nwhere ar.frequency != 'one_off';\n\nselect s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;\nselect * from nudges n where n.activity_search_id\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;\n\nselect * from users where team_id = 1 and name like '%Lukas%'; # 7160\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\nSELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,\nselect * from opportunities where team_id = 1126;\nSELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,\nselect * from opportunities where team_id = 1125;\nselect * from contacts c\nwhere c.team_id = 882;\n\nSELECT * FROM activities WHERE id = 76822967;\nSELECT * FROM crm_profiles WHERE user_id = 15440;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 555;\nSELECT * FROM crm_configurations WHERE id = 555;\nSELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 581 and sa.provider = 'salesforce';\n\nSELECT * FROM automated_report_results order by id desc;\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556;\n\nselect * from automated_reports;\nwhere id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , [\"pdf\",\"podcast\"]\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\nselect * from automated_report_results order by id desc;\nSELECT * FROM automated_report_results WHERE id = 1919;\n\nselect * from automated_report_results WHERE report_id = 54;\n\nselect * from opportunities where id = 7594349;\n\nSELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - jiminnyintegration@lesmills.com\nselect * from playbooks where team_id = 711; # event 226147\nSELECT * FROM playbook_categories WHERE playbook_id = 5515;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';\nSELECT * FROM crm_fields WHERE id = 226147;\nSELECT * FROM crm_field_values WHERE crm_field_id = 226147;\n\nSELECT * FROM crm_configurations WHERE id = 692;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 711 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;\n\nselect * from leads;\n\nselect * from calendars;\n\nSELECT\n t.id AS team_id,\n t.name,\n LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain\nFROM teams t\nJOIN users u ON u.team_id = t.id\nJOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'\nLEFT JOIN team_domains td\n ON td.team_id = t.id\n AND td.deleted_at IS NULL\n AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))\nGROUP BY t.id, t.name, calendar_domain\nORDER BY t.name, calendar_domain;\n\nselect * from users u join calendars c on c.user_id = u.id\nwhere u.team_id = 882;\n\n\nselect * from activities where id = 74049485; # team 563 crm 537\nselect * from activities where id = 73272382; # team 563 crm 537\nselect * from activities where id = 64400389; # team 563 crm 537\nselect * from activities where id = 58081273; # team 563 crm 537\nselect * from activities where id = 54520297; # team 563 crm 537\nselect * from participants where activity_id = 58081273;\n\nselect * from activities where crm_configuration_id = 537 and provider = 'aircall'\nand account_id = 19003658 order by updated_at desc;\n\nselect * from contacts where crm_configuration_id = 537 and id = 35957759;\nselect * from accounts where crm_configuration_id = 537 and id = 19003658;\n\nselect * from automated_report_results where id = 1976;\nselect * from automated_reports where id = 583;\nselect * from activity_searches where id = 87714;\nselect * from activity_search_filters where activity_search_id = 87714;\n\nSELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid\nor uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot';\nselect * from rate_limits;\n\nselect * from automated_report_results where media_type = 'pdf' and status = 2\nand id IN (18, 1872);\nselect * from automated_reports where id = 54;\nSELECT * FROM users WHERE id IN (24623,29443,29613);\n\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\n\nselect * from text_relays where created_at > '2026-05-01'\n# and id IN (32415, 32416);\nand id = 32412;\n\nselect * from users where team_id = 2 and email like '%scott%' and id = 29510;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3905945560318828710
|
2218600144103159365
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20891-improve-sms-text Project: faVsco.js, menu
JY-20891-improve-sms-text-relays, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Code changed:
Hide
Sync Changes
Hide This Notification
2
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Repositories;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Jiminny\Component\Cache;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\UserRepository as UserRepositoryContract;
use Jiminny\Models\Contracts\UserContract;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Group;
use Jiminny\Models\LanguageDialect;
use Jiminny\Models\Participant;
use Jiminny\Models\Partner;
use Jiminny\Models\Playlist;
use Jiminny\Models\Playlist\Share;
use Jiminny\Models\Role;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Jiminny\Models\UserSettings;
class UserRepository implements UserRepositoryContract
{
private Cache\Service\Storage $storage;
public function __construct(Cache\Service\Storage $storage)
{
$this->storage = $storage;
}
public function current(): ?User
{
return Auth::user();
}
/**
* {@inheritdoc}
*/
public function find(int $id): (Authenticatable&UserContract)|null
{
return User::where('id', $id)->first();
}
public function get(int $id): UserContract
{
$user = $this->find($id);
if ($user === null) {
throw new ModelNotFoundException();
}
return $user;
}
public function findByUuid(string $uuid): ?User
{
/** @var User|null */
return User::where('uuid', User::toOptimized($uuid))->first();
}
public function search(string $query, ?User $excludeUser, Partner $partner): Collection
{
/** @var Builder $search */
$search = User::query()
->where('status', User::STATUS_ACTIVE)
->whereHas('team', static function (Builder $query) use ($partner): void {
// Never allow partners to search for other partner users.
if (! $partner->isDefaultPartner()) {
$query->where('teams.partner_id', $partner->id);
}
});
// If a user to exclude was passed to the repository, we will exclude their User
// ID from the list. Typically we don't want to show the current user in the
// search results and only want to display the other users from the query.
if ($excludeUser !== null) {
$search->where('id', '<>', $excludeUser->getId());
}
/** @var Collection<User> */
return $search
->where(static function (Builder $search) use ($query) {
$search
->where('email', 'like', $query)
->orWhere('name', 'like', $query);
})
->get();
}
public function findOneBy(array $criteria): ?User
{
$query = User::query();
foreach ($criteria as $attribute => $value) {
$query->where($attribute, $value);
}
/** @var User $result */
$result = $query->first();
return $result;
}
public function findInaccessibleDeactivatedUserUuids(User $user): Collection
{
return $this->storage->remember(
static function () use ($user): Collection {
if ($user->hasRole(User::MANAGER_ROLES)) {
return Collection::make();
}
$team = $user->getTeam();
if ($team->canViewActivitiesOfDeactivatedUsers()) {
return Collection::make();
}
return $team->users()
->where('status', '=', User::STATUS_DEACTIVATED)
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
},
Cache\Constants::deactivatedUsersIds($user),
static fn () => Carbon::now()->addMinute(),
);
}
public function findDeactivatedUserUuids(Team $team): Collection
{
return $team->users()
->where('status', '=', User::STATUS_DEACTIVATED)
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
}
public function findUserUuids(Team $team): Collection
{
return $team->users()
->get(['uuid'])
->map(static fn (User $teamMember): string => $teamMember->getUuid());
}
public function idOrUuId(int|string $userIdentifier): User
{
/** @var User */
return User::idOrUuId($userIdentifier);
}
public function findBySharedPlaylist(Playlist $playlist): Collection
{
// If 'shares' relation is already loaded
if ($playlist->relationLoaded('shares')) {
$users = Collection::make();
// Get list of users the playlist is shared to
$shares = $playlist->getShares()->unique('to_user_id');
foreach ($shares as $share) {
$users->add($share->to);
}
return $users
->where('status', '=', User::STATUS_ACTIVE)
->sortBy('name');
}
/** @var Collection<int, User> */
return $playlist->hasManyThrough(
User::class,
Share::class,
'playlist_id',
'id',
'id',
'to_user_id',
)
->active()
->orderBy('name', 'asc')
->distinct()
->get();
}
public function updateOrCreateCrmProfile(User $user, string $crmProviderId): Profile
{
/** @var Profile */
return $user->crmProfile()->updateOrCreate([
'crm_configuration_id' => $user->getTeam()->getCrmConfiguration()->getId(),
'crm_provider_id' => $crmProviderId,
]);
}
/**
* Get all Crm Fields, related to the crm configuration for the team of which the user is a member of.
* The CrmFields collection can optionally be filtered by field properties.
*
* @return Collection<Field>
*/
public function getUserCrmConfigurationFields(UserContract $user, array $filterFields = []): Collection
{
$hasManyQueryBuilder = $user->getTeam()
->getCrmConfiguration()
->fields();
if (! empty($filterFields)) {
foreach ($filterFields as $filterKey => $filterValue) {
$hasManyQueryBuilder->where($filterKey, $filterValue);
}
}
return $hasManyQueryBuilder->get();
}
/**
* @return Collection<User>
*/
public function findTeamUsersWithMostCapableRole(int $teamId, string $providerSlug): Collection
{
$roleIds = Role::whereIn('name', [
User::ROLE_ADMIN,
User::ROLE_MANAGER,
User::ROLE_RECORDER,
])
->pluck('id')
->toArray();
return User::select('users.*')
->where('users.team_id', $teamId)
->where('users.status', User::STATUS_ACTIVE)
->join('social_accounts as sa', static function (JoinClause $q) use ($providerSlug) {
return $q->on('sa.sociable_id', '=', 'users.id')
->where([
'sa.provider' => $providerSlug,
'sa.state' => SocialAccount::STATE_CONNECTED,
]);
})
->join('role_user as ru', static function (JoinClause $q) use ($roleIds) {
return $q->on('ru.user_id', '=', 'users.id')
->whereIn('ru.role_id', $roleIds);
})
->orderBy('ru.role_id', 'ASC')
->get();
}
public function getUsersWithInboxByUserIds(array $userIds): Collection
{
return User::with(['Inbox'])->find($userIds);
}
public function getCustomerApiTeamMembers(int $teamId): Collection
{
return User::select(['id', 'uuid', 'name', 'email', 'status'])
->with(['roles'])
->where('team_id', $teamId)
->addSelect([
'teamName' => Group::select(['name'])
->whereColumn('id', 'users.group_id')
->limit(1),
'teamID' => Group::selectRaw('uuid_from_bin(groups.uuid)')
->whereColumn('id', 'users.group_id')
->limit(1),
])
->addSelect([
'crmID' => Profile::select(['crm_provider_id'])
->whereColumn('user_id', 'users.id')
->whereNotNull('crm_provider_id')
->limit(1),
])
->get();
}
public function findActiveThatSyncDialersByTeamAndCrmProviderId(Team $team, string $crmProviderId): ?User
{
return User::where('team_id', $team->getId())
->active()
->shouldSyncDialers()
->whereHas('crmProfile', static function (Builder $query) use ($crmProviderId): void {
$query->where('crm_provider_id', $crmProviderId);
$query->whereNull('archived_at');
})
->first();
}
public function getFirstTeamAdmin(Team $team, string $orderBy = 'id', string $orderDirection = 'asc'): ?User
{
return $team
->users()
->active()
->admin()
->orderBy($orderBy, $orderDirection)
->first();
}
public function disableSlackFollowUp(User $user): void
{
$user->disableSlackFollowUp();
$user->save();
}
public function enableSlackFollowUp(User $user): void
{
$user->enableSlackFollowUp();
$user->save();
}
public function findActiveByRoles(Team $team, array $roles): Collection
{
/** @var Collection<int, User> */
return $team->users()
->whereHas(
relation: 'roles',
callback: static fn (Builder $query): Builder => $query->whereIn(
column: 'name',
values: $roles,
),
)
->active()
->get();
}
public function findByTeamAndEmailAddress(
Team $team,
string $email,
bool $onlyActive = false,
bool $onlyPrimary = false,
): ?User {
/** @var QueryBuilder $query */
$query = User::where('team_id', $team->getId())
->where(function ($query) use ($email, $onlyPrimary) {
$query->where('email', $email);
if (! $onlyPrimary) {
$query->orWhere('secondary_email', $email);
}
})
->orderBy('status');
if ($onlyActive) {
$query->active();
}
/** @var User|null */
return $query->first();
}
public function findByEmail(string $email): ?User
{
/** @var User|null */
return User::query()
->whereNested(
static function (QueryBuilder $query) use ($email) {
$query
->where('email', $email)
->orWhere('secondary_email', $email);
}
)
->active()
->first();
}
public function findByPhone(string $phone): ?User
{
return User::where('phone', $phone)->first();
}
public function findBySecondaryPhone(string $secondaryPhone): ?User
{
return User::where('secondary_phone', $secondaryPhone)->first();
}
public function getUserLocales(User $user): array
{
$userLanguages = [];
$user->load('languageDialects.language');
/** @var LanguageDialect $dialect */
foreach ($user->languageDialects()->get() as $dialect) {
$userLanguages[] = $dialect->getLanguageLocale();
}
return $userLanguages;
}
public function getUserLanguageLocales(User $user): array
{
$userLanguages = [];
$user->load('languageDialects.language');
/** @var LanguageDialect $dialect */
foreach ($user->languageDialects()->get() as $dialect) {
$userLanguages[] = $dialect->getLanguage()->getLocale();
}
return array_unique($userLanguages);
}
public function updateUserLocales(User $user, array $locales): void
{
$dialectIds = [];
foreach ($locales as $locale) {
[$languageLocale, $dialectLocale] = explode('_', $locale);
$dialect = LanguageDialect::whereHas('language', function ($query) use ($languageLocale) {
$query->where('locale', $languageLocale);
})
->where('locale', $dialectLocale)
->first();
if ($dialect) {
$dialectIds[] = $dialect->getId();
}
}
$user->languageDialects()->sync($dialectIds);
}
public function getUserSetting(int $userId, string $settingName): ?UserSettings
{
/** @var ?UserSettings */
return UserSettings::query()
->where('user_id', $userId)
->where('name', $settingName)
->first();
}
public function setUserSetting(int $userId, string $name, mixed $value, string $valueType): UserSettings
{
return UserSettings::updateOrCreate(
['user_id' => $userId, 'name' => $name],
['value' => (string) $value, 'value_type' => $valueType],
);
}
public function enableCrm(User $user): void
{
$user->update(['crm_required' => true]);
}
public function getByTeamAndActivitiesParticipants(Team $team, array $activityIds): Collection
{
$emails = Participant::select('email')
->distinct()
->whereIn('activity_id', $activityIds)
->pluck('email');
return $team->users()
->whereIn('email', $emails)
->active()
->whereHasPermission(PermissionEnum::CALENDAR_SYNC)
->where('sync_conference', true)
->where('team_id', $team->getId())
->get();
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
40
1
40
65
Previous Highlighted Error
Next Highlighted Error
SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993
SELECT * FROM users WHERE id = 25061;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 994;
SELECT * FROM crm_profiles WHERE user_id = 25061;
select * from crm_configurations where id = 834;
SELECT * FROM teams WHERE id = 882;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;
SELECT * FROM contacts where crm_configuration_id = 834;
SELECT * FROM opportunities WHERE team_id = 933
# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');
AND id IN (8482561,18352941,19042734,19232139,19445140,19472541);
SELECT * FROM opportunity_contacts
WHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 485; #
SELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
select crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id
where crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')
# and l.converted_at IS NOT NULL
;
# [PASSWORD_DOTS]
SELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')
and opportunity_id IS NULL
order by id desc;
SELECT * FROM teams WHERE id = 604; # 598
SELECT * FROM activities WHERE id = 74410828; # [EMAIL]
SELECT * FROM accounts WHERE id = 20068382;
SELECT * FROM accounts WHERE id = 35186038;
SELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 559 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;
select * from sidekick_settings where team_id = 781;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100
SELECT * FROM crm_layouts WHERE crm_configuration_id = 711;
SELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL
and is_internal = 0 and status = 'completed'
order by id desc;
SELECT * FROM crm_layout_entities
WHERE crm_layout_id IN (2352, 2353);
;
SELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 556 and sa.provider = 'hubspot';
SELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;
SELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;
select * from contacts
where crm_configuration_id = 530
and crm_provider_id = 872252;
select * from activities where crm_configuration_id = 530
and user_id = 14343 and type like '%softphone%'
and created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya
SELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);
SELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t
JOIN crm_configurations c ON t.id = c.team_id
WHERE t.status = 'active';
SELECT * FROM teams where id = 1091;
SELECT * FROM crm_configurations where team_id = 1091;
SELECT * FROM activity_providers where team_id = 1091;
SELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT * FROM teams WHERE name LIKE '%Leadventure%';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1091 and sa.provider = 'salesforce';
SELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812
SELECT * FROM teams where id = 862;
SELECT * FROM crm_configurations where team_id = 862;
SELECT * FROM activity_providers where team_id = 862;
SELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')
and provider NOT IN ('hubspot', 'aircall')
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by id desc;
SELECT t.id, crm.id, crm.provider, ap.* FROM teams t
join crm_configurations crm on t.id = crm.team_id
join activity_providers ap on t.id = ap.team_id
where t.status = 'active' and ap.is_enabled = 1
and crm.provider = 'hubspot'
and ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',
'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');
SELECT * FROM teams where id = 1068;
SELECT * FROM crm_configurations where team_id = 1068;
SELECT * FROM activity_providers where team_id = 1068;
SELECT * FROM activities a
where crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')
and a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'
)
# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'
order by a.id desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1068 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 933 and sa.provider = 'hubspot';
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262
SELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 882 and sa.provider = 'hubspot';
select * from crm_layouts where crm_configuration_id = 834;
select * from crm_layout_entities where crm_layout_id = 2780;
select * from crm_fields where id IN (321153,321192,321193,321194);
SELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1057 and sa.provider = 'hubspot';
SELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8
SELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20
SELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last
SELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10
SELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;
SELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #
SELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;
select * from users where team_id = 51; # 7783
SELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130
select * from activity_searches where user_id = 7783;
select * from activity_search_filters where activity_search_id IN (32291, 32292);
SELECT asf.activity_search_id, asf.id, asf.value
FROM activity_search_filters asf
WHERE asf.filter = 'group_id'
AND asf.value IN (
SELECT CONCAT(
HEX(SUBSTR(uuid, 5, 4)), '-',
HEX(SUBSTR(uuid, 3, 2)), '-',
HEX(SUBSTR(uuid, 1, 2)), '-',
HEX(SUBSTR(uuid, 9, 2)), '-',
HEX(SUBSTR(uuid, 11))
)
FROM groups
WHERE deleted_at IS NOT NULL
);
SELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th
# [PASSWORD_DOTS]
SELECT * FROM crm_configurations where provider = 'hubspot';
SELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133
SELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;
SELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null
# [PASSWORD_DOTS]
select * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';
select
cp.*
# DISTINCT t.id
# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields
FROM crm_profiles cp
JOIN crm_configurations crm on crm.id = cp.crm_configuration_id
JOIN users u on u.id = cp.user_id
JOIN teams t ON t.id = crm.team_id
WHERE crm.provider = 'salesforce' and t.status = 'active'
and cp.archived_at IS NULL and u.deleted_at IS NULL
and t.id NOT IN (1093)
and t.id = 2
and cp.contact_fields IS NULL;
# and c.crm_provider_id = '003Uu00000ojD4NIAU';
SELECT * FROM users WHERE id = 26484;
SELECT * FROM crm_profiles WHERE user_id = 26484;
SELECT * FROM social_accounts WHERE sociable_id = 26484;
SELECT * FROM crm_configurations where provider = 'salesforce';
select * from users where id IN (10022, 10403);
select * from users where team_id IN (526);
select * from teams where id IN (526, 532);
select * from crm_configurations where id IN (500, 516);
select * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);
select * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 526 and sa.provider = 'salesforce';
select * from team_settings where team_id IN (526, 532);
select * from users where id IN (22824);
select * from crm_profiles where crm_configuration_id IN (1026);
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1093 and sa.provider = 'salesforce';
select * from teams where id = 1099;
select * from users where id = 29643
select * from activity_processing_states;
SELECT * FROM teams where name LIKE '%Fare%'; # 233
SELECT * FROM opportunities where crm_configuration_id = 215
# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'
;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1088 and sa.provider = 'hubspot';
SELECT * FROM teams order by updated_at DESC
SELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account
select * from crm_configurations where provider = 'pipedrive';
select * from teams where id = 957;
select * from crm_configurations where id = 957;
SELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743
SELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;
select * from users where team_id = 1; # 26726 - Gabriela Dureva
SELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific
select * from activities where user_id = 26726 order by id desc;
select * from contacts where crm_configuration_id = 1
and email IN ('[EMAIL]', '[EMAIL]'); # 2094416, 2093620
SELECT * FROM contacts WHERE id = 6284931;
SELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id
WHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;
select * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);
select * from crm_configurations where id = 1;
43801692-1aeb-32ce-acba-5b80a479701a
44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b
405975c0-b3d0-7aaa-821f-09d59cae6dd1
4caf848d-4bed-2299-b248-7788d41f9fca
49bedc3f-f196-eef3-89c3-dea6a3b4aa63
43420989-a09d-b8f8-9806-c8bbf7a02aac
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
SELECT * FROM activities WHERE id = 75461988;
SELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;
select * from contacts where id = 17900517;
select * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id
where crm.provider != 'salesforce';
select * from users where id = 21047;
SELECT * FROM crm_configurations WHERE id = 892;
SELECT * FROM teams WHERE id = 942;
select * from opportunities where team_id = 942 order by updated_at desc;
select * from contacts where team_id = 942 order by updated_at desc;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 942 and sa.provider = 'hubspot';
SELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430
SELECT * FROM crm_configurations WHERE id = 1;
SELECT * FROM teams WHERE crm_id = 1;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1 and sa.provider = 'salesforce';
select id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1
SELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430
select * from teams where id = 852;
select * from groups where id = 2286;
select * from sidekick_settings where team_id = 852;
select * from default_activity_types where team_id = 852;
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1 AND u.deleted_at IS NULL
AND u.crm_required = 1
AND u.team_id = 1
ORDER BY u.team_id;
SELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (
18481
);
SELECT cc.provider, cc.id, p.id, u.*
FROM users u
LEFT JOIN crm_profiles p ON u.id = p.user_id
INNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'
INNER JOIN crm_configurations cc ON t.crm_id = cc.id
WHERE u.status = 1
AND u.deleted_at IS NULL
AND u.crm_required = 1
# AND u.team_id = 1
AND p.id IS NULL -- Move this condition to WHERE clause
ORDER BY u.team_id;
SELECT * FROM opportunities WHERE id = 20002609;
select * from teams where id = 1122; # Velatir, 29953 - [EMAIL]
select * from crm_configurations where id = 1060;
select * from crm_layouts where crm_configuration_id = 1060;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1122 and sa.provider = 'hubspot';
select * from opportunities where team_id = 1122 order by updated_at desc;
select * from crm_field_data where object_type = 'contact';
SELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 248 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS
SELECT * FROM users where id = 24115;
SELECT * FROM accounts where id = 4002896;
SELECT * FROM teams WHERE name LIKE '%adswerve%';
SELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN ("0069N000003GIQ9QAO","0061r000019yGP9AAM","0066900001S2KWlAAN","0066900001TDpj2AAD","0066900001b8uEwAAI","0069N000001rQi0QAE","006QF00000KD40mYAD","006QF00000LzpRJYAZ","0069N000002uomtQAA","0069N000002xlMLQAY","0066900001NV6ubAAD","0061r00001HJp45AAD","006QF00000uTlUoYAK","006QF00000v0bZqYAI");
SELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203
SELECT u.id, u.email, ac.name, a.* FROM activities a
JOIN users u ON a.user_id = u.id
JOIN accounts ac ON a.account_id = ac.id
WHERE
uuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or
uuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or
uuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;
select * from users where id = 5825;
SELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;
select * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;
19594, 862
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 862 and sa.provider = 'salesforce';
select * from automated_reports where id = 36;
select ar.frequency, r.*, ar.* from automated_report_results r
join automated_reports ar on r.report_id = ar.id
where ar.frequency != 'one_off';
select s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;
select * from nudges n where n.activity_search_id
select * from teams where created_at > '2026-03-09';
SELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;
select * from users where team_id = 1 and name like '%Lukas%'; # 7160
SELECT * FROM teams WHERE id = 575;
select * from opportunities where team_id = 575;
SELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,
select * from opportunities where team_id = 1126;
SELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,
select * from opportunities where team_id = 1125;
select * from contacts c
where c.team_id = 882;
SELECT * FROM activities WHERE id = 76822967;
SELECT * FROM crm_profiles WHERE user_id = 15440;
SELECT * FROM crm_profiles WHERE crm_configuration_id = 555;
SELECT * FROM crm_configurations WHERE id = 555;
SELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 581 and sa.provider = 'salesforce';
SELECT * FROM automated_report_results order by id desc;
select * from features;
select * from team_features where feature_id = 40;
select * from teams where id = 556;
select * from automated_reports;
where id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , ["pdf","podcast"]
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from automated_report_results order by id desc;
SELECT * FROM automated_report_results WHERE id = 1919;
select * from automated_report_results WHERE report_id = 54;
select * from opportunities where id = 7594349;
SELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - [EMAIL]
select * from playbooks where team_id = 711; # event 226147
SELECT * FROM playbook_categories WHERE playbook_id = 5515;
SELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';
SELECT * FROM crm_fields WHERE id = 226147;
SELECT * FROM crm_field_values WHERE crm_field_id = 226147;
SELECT * FROM crm_configurations WHERE id = 692;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 711 and sa.provider = 'salesforce';
SELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;
select * from leads;
select * from calendars;
SELECT
t.id AS team_id,
t.name,
LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain
FROM teams t
JOIN users u ON u.team_id = t.id
JOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'
LEFT JOIN team_domains td
ON td.team_id = t.id
AND td.deleted_at IS NULL
AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))
GROUP BY t.id, t.name, calendar_domain
ORDER BY t.name, calendar_domain;
select * from users u join calendars c on c.user_id = u.id
where u.team_id = 882;
select * from activities where id = 74049485; # team 563 crm 537
select * from activities where id = 73272382; # team 563 crm 537
select * from activities where id = 64400389; # team 563 crm 537
select * from activities where id = 58081273; # team 563 crm 537
select * from activities where id = 54520297; # team 563 crm 537
select * from participants where activity_id = 58081273;
select * from activities where crm_configuration_id = 537 and provider = 'aircall'
and account_id = 19003658 order by updated_at desc;
select * from contacts where crm_configuration_id = 537 and id = 35957759;
select * from accounts where crm_configuration_id = 537 and id = 19003658;
select * from automated_report_results where id = 1976;
select * from automated_reports where id = 583;
select * from activity_searches where id = 87714;
select * from activity_search_filters where activity_search_id = 87714;
SELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid
or uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;
SELECT * FROM crm_configurations WHERE provider = 'hubspot';
select * from rate_limits;
select * from automated_report_results where media_type = 'pdf' and status = 2
and id IN (18, 1872);
select * from automated_reports where id = 54;
SELECT * FROM users WHERE id IN (24623,29443,29613);
SELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;
select * from text_relays where created_at > '2026-05-01'
# and id IN (32415, 32416);
and id = 32412;
select * from users where team_id = 2 and email like '%scott%' and id = 29510;
Project
Project
New File or Directory…...
|
34750
|
NULL
|
NULL
|
NULL
|
|
62370
|
2211
|
43
|
2026-05-20T11:32:45.567213+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779276765567_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2742043218700313318
|
-8635300380982929453
|
app_switch
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
PhostormVIeWINavigarecodeLaravelWindowFV faVsco.js~#12092 on JY-20613-allow-owner-rolProletey@ UserinvitationDTOTest.php X› D ConferenceHandlerv D ConferenceManager© Client.phpClientTest.php© EditTeamRequest.phps:phpesortPnonemanager.ong• ConferenceCallbackHandler.php© ConferenceManager.phpnamespace lests reacure uruDDIO› DEvent>D Exception>use...14 Ptinal class Userinvitationdtulest extends Testcasew Job• Resolveiuse DatabaselransactionsService,DVO18 Voublic function testcreate-rominvitat1ono vo1d© TwilioConstants.phpc)TwiioReoositorv.ono** @var Invitation Sinvitation *^Uoloaden$invitation = Invitation::factory()->create([7 UriGeneratorQUtilityM Uuid• Waveform'email''[EMAIL]','group_id' => 1,iteam 1dt = 11'role_id' => null,N Wehhooks'erm_required' = 1,D Workflow'is_owner' => 0> D ConfigurationD Console• D Commands› D Activities/** @var User $user */$user = User::factory()->create();> D Analytics>D Calendars*x ava Kole suserkole x/$userRole = Role: :whereName( value: User::ROLE_RECORDER)->first();v0 erm> D Hubspot› IntegrationAppsinvitation->roles->sync suserkole>D Traitssato = Userinvitat1onbT0::rrom.nv1tations1nv1tation, suserc) AddLavoutEntities.phpc) AutoloaDelavedcommand.phoself::assertsame( expected: "testogma1l.com, $dto->ema1l);c) BacktillOpportunityUserFromaccountC3ullhorncommandAostract.onoself::assertSame exicted: 1. Soto->teamid):C) BullhornPinaCommand.ohvC) BullhornSearchCommand.onvself::assertSamed SuserRole-›cetido. sdto->rolelds):C) BullhornSessionCommand.ohv@ CheckActivitvLoagableCommand.ohp© CleanDuplicateFieldDataCommand.ohC) [EMAIL] function testFor0wner: voidt...}@ LoaActivitiesCommand.ohnC) ManadeSvncStrateavCommand.nhnlpublic function testFor0wnerWithRecorderAndVoiceRole: void(...}C) [EMAIL]) Match@nnortunitvActivitiesCommandl1A5 VSpublic function testFor0wnerWithAnalystRole: void(...}C) MiarateProvider nhn.C) ProceccHuhsnot@biectsSvncRatches© PurgeDeletedOpportunitiesCommand.© ResetGovernorLimits.php© SendNotLogged.php© SetupActivityTypeForFollowUp.phplihl"suppont Dally • In zom100% L28• Wed 20 May 14:32:45CascadeU UserInvitationDTOTestTesting SmsReceivedFix UserinvitationdteCascade+O •E laravel.logtii jiminny.users [PROD]y Kernelpnp4 SF [jiminny@localhost]A console [EU]A console [STAGING]© SoftPhoneManager.php xA HS_Jocal (jiminny@localhost]© TextMessagingService.phpA console [Pkol)tid jiminny.users (EU]fiii crm_configurations (EU]Tinal readonly class sortrnonenanacer anolements conterenceranaderpublic const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';M e8.1^public function __construct(private ProviderRegistry $crmProviderRegistry,private Webhook $urlGenerator,private ActivityService $activityService,private LoggerInterface $logger,private TwilioClientBuilder $twilioClientBuilder,44 € >public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity{……}private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activit$startTime = microtime(as_float: true);Sthis->logger->debug(_METHOD,['data' => $data,Snow = nowobSuser = $data->getUser();Steam = Suser->qetTeamolSdevice = Sdata->qetdevice@:SphoneNumber = Scata->qetPhoneNumbero:Scoute = sdata->oetPhoneRouteoif (! Sthis-›activityService-›teamCanDial(Steam, $phoneNumber)) €Srearon = aetReaionBvNumberSohoneNumber Susen->aetCountrvCode0n=throw new LogicException(sprintf('Sorry, dialing %s is not available with your subscription.'Please contact your manager to enable additional destinations.',...values: Sregion ?? 'this number'101$lead = null;Saccount = null:sconcact = nuul:Sstage = nuuuiSopportunity = null;103II Setup services.ScrmService = Sthis->crmProviderRegistry->qet($team->crm->provider)if (! Suser-›isCrmRequired()) €ScrmService->setuser(Suser->team->owner)Cascade Code *•Kick off a new project. Make changesacross your entre codedase• Testina SmsReceived NotificationFix UserinvitationDTotes© Text Relay Email Alias Setup and FilteringAsk anvthina (884-L)« Code SWF-1.6W Windsurf Teams24-28f 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
62369
|
2210
|
27
|
2026-05-20T11:32:45.581933+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779276765581_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2742043218700313318
|
-8635300380982929453
|
app_switch
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
SlackFileEditViewGoHistoryWindowHelpAPPDOCKER• 81DEV (-zsh)₴82To github.com:jiminny/app.gitc4e163f3e7..f885e531abJY-20613-allow-owner-role-on-team-setup -> JY-20613-allow-ownerlukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20613-allow-owner-role-on-team-On branch JY-20915-add-domain-specific-email-text-relayYour branch is up to date with 'origin/JY-20915-add-domain-specific-email-text-relay'.Changes to becommitted:(use "git restore --staged ‹file>..." to unstage)new file:tests/Unit/Notifications/Activities/SmsReceivedTest.phpChanges not staged for commit:Cuse"gitadd‹file›...to updatewhat willbecommitted)Cuse"git restore ‹file›...to discard changesin working directory)modified:.env.localmodified:app/Console/Commands/JiminnyDebugCommand.phpmodified:config/logging.phpUntracked files:Cuse "git add<file>..."to include in what will be committed)env.nikilocalenv.otherWEBHOOK_FILTERING_IMPLEMENTATION.mdapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.phpapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.phpids.txtpublic/favicon.icoraw_sql_query.sqltests/Unit/Policies/CanAccessAiReportsTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20915-add-domain-specific-emaidocker exec-it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.plPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loadedconfig default from".php-cs-fixer.dist.php".5690/5690 [100%Fixed 0 of 5690 files in 76.177 seconds, 60.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in any containeror image »Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20915-add-domain-specific-emaiHomeDMsActivityFilesLater...More+(aholSupport Daily • in 28 m100% <78• Wed 20 May 14:32:45→Describe what you are looking forJiminny ...# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...° Direct messagesP. Nikolay YankovP. Aneliya Angelova. Stoyan Tanevdo James Graham&. Stefka Stoyanova% Galya DimitrovaVasil Vasilev. Stoyan Tomov&: Todor Stamatov. Mario GeorgievLukas Kovalik y...##: AppsToastJira Cloud# ai-chapter8 176 0Messagest Add canvas@ Files+anWednesday, April 22nd~Vasil Vasilev 5:52 PMBinary01full-es-dataset.txtBinaryThis is a single recordVes 6:20 PMjoined #ai-chapter.Nikolay Yankov 7:01 PMhttps://app.jiminny.com/playback/acbbf9e0-6dea-4887-863b-86f7015a687cToday ~NewSteliyan Georgiev 1:59 PMThe Al work we do at Jiminny is mostly in the textmodality, while the voice-to-text transition ishandled by external services.Let's talk about the voice modality during tonight'sAl Chapter call - what it is, how we can utilize it atJiminny, current limitations, etc.To be honest, I'm also fairly new to this, so I'll spendsome time this afternoon preparing myself.d 1Message #ai-chapter+...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
62326
|
2210
|
11
|
2026-05-20T11:31:33.027307+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779276693027_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPPDOCKER• 81D SlackFileEditViewGoHistoryWindowHelpAPPDOCKER• 81DEV (-zsh)₴82To github.com:jiminny/app.gitc4e163f3e7..f885e531abJY-20613-allow-owner-role-on-team-setup -> JY-20613-allow-owneilukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20613-allow-owner-role-on-team-On branch JY-20915-add-domain-specific-email-text-relayYour branch is up to date with 'origin/JY-20915-add-domain-specific-email-text-relay'.Changes to becommitted:(use "git restore --staged ‹file>..." to unstage)new file:tests/Unit/Notifications/Activities/SmsReceivedTest.phpChanges not staged for commit:Cuse"gitadd‹file›...to updatewhat willbecommitted)Cuse"git restore ‹file›...to discard changesin working directory)modified:.env.localmodified:app/Console/Commands/JiminnyDebugCommand.phpmodified:config/logging.phpUntracked files:Cuse "git add<file>..."to include in what will be committed)env.nikilocalenv.otherWEBHOOK_FILTERING_IMPLEMENTATION.mdapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.phpapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.phpids.txtpublic/favicon.icoraw_sql_query.sqltests/Unit/Policies/CanAccessAiReportsTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20915-add-domain-specific-emaidocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.plPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loadedconfig default from".php-cs-fixer.dist.php".5690/5690 [100%Fixed 0 of 5690 files in 76.177 seconds, 60.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in any containeror image »Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20915-add-domain-specific-emaiHomeDMsActivityFilesLater...More+(aholSupport Daily • in 29 m100% <78• Wed 20 May 14:31:32→Describe what you are looking forJiminny ...# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...° Direct messagesP. Nikolay YankovP. Aneliya Angelova. Stoyan Tanevdo James Graham&. Stefka Stoyanova% Galya DimitrovaVasil Vasilev. Stoyan Tomov&: Todor Stamatov. Mario GeorgievLukas Kovalik y...##: AppsToastJira Cloud# ai-chapter8 176 0Messagest Add canvas@ Files+anWednesday, April 22nd~Vasil Vasilev 5:52 PMBinary01full-es-dataset.txtBinaryThis is a single recordVes 6:20 PMjoined #ai-chapter.Nikolay Yankov 7:01 PMhttps://app.jiminny.com/playback/acbbf9e0-6dea-4887-863b-86f7015a687cToday ~NewSteliyan Georgiev 1:59 PMThe Al work we do at Jiminny is mostly in the textmodality, while the voice-to-text transition ishandled by external services.Let's talk about the voice modality during tonight'sAl Chapter call - what it is, how we can utilize it atJiminny, current limitations, etc.To be honest, I'm also fairly new to this, so I'll spendsome time this afternoon preparing myself.d 1Message #ai-chapter+...
|
NULL
|
-4634925461751847435
|
NULL
|
click
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPPDOCKER• 81D SlackFileEditViewGoHistoryWindowHelpAPPDOCKER• 81DEV (-zsh)₴82To github.com:jiminny/app.gitc4e163f3e7..f885e531abJY-20613-allow-owner-role-on-team-setup -> JY-20613-allow-owneilukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20613-allow-owner-role-on-team-On branch JY-20915-add-domain-specific-email-text-relayYour branch is up to date with 'origin/JY-20915-add-domain-specific-email-text-relay'.Changes to becommitted:(use "git restore --staged ‹file>..." to unstage)new file:tests/Unit/Notifications/Activities/SmsReceivedTest.phpChanges not staged for commit:Cuse"gitadd‹file›...to updatewhat willbecommitted)Cuse"git restore ‹file›...to discard changesin working directory)modified:.env.localmodified:app/Console/Commands/JiminnyDebugCommand.phpmodified:config/logging.phpUntracked files:Cuse "git add<file>..."to include in what will be committed)env.nikilocalenv.otherWEBHOOK_FILTERING_IMPLEMENTATION.mdapp/Console/Commands/Crm/Hubspot/SimulateWebhooksCommand.phpapp/Console/Commands/Reports/CreateMockAskJiminnyReportResultCommand.phpids.txtpublic/favicon.icoraw_sql_query.sqltests/Unit/Policies/CanAccessAiReportsTest.phplukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20915-add-domain-specific-emaidocker exec -it docker_lamp_1 ./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.plPHP CS Fixer 3.87.1 Alexander by Fabien Potencier, Dariusz Ruminskiandcontributors.PHPruntime: 8.3.30Running analysis on 7 cores with 10 files per process.Parallel runner is an experimental feature and may be unstable, use it at your own risk.Loadedconfig default from".php-cs-fixer.dist.php".5690/5690 [100%Fixed 0 of 5690 files in 76.177 seconds, 60.00 MB memory usedWhat's next:Try Docker Debug for seamless, persistent debugging tools in any containeror image »Learn more at https://docs.docker.com/go/debug-cli/lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20915-add-domain-specific-emaiHomeDMsActivityFilesLater...More+(aholSupport Daily • in 29 m100% <78• Wed 20 May 14:31:32→Describe what you are looking forJiminny ...# engineering# general# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...° Direct messagesP. Nikolay YankovP. Aneliya Angelova. Stoyan Tanevdo James Graham&. Stefka Stoyanova% Galya DimitrovaVasil Vasilev. Stoyan Tomov&: Todor Stamatov. Mario GeorgievLukas Kovalik y...##: AppsToastJira Cloud# ai-chapter8 176 0Messagest Add canvas@ Files+anWednesday, April 22nd~Vasil Vasilev 5:52 PMBinary01full-es-dataset.txtBinaryThis is a single recordVes 6:20 PMjoined #ai-chapter.Nikolay Yankov 7:01 PMhttps://app.jiminny.com/playback/acbbf9e0-6dea-4887-863b-86f7015a687cToday ~NewSteliyan Georgiev 1:59 PMThe Al work we do at Jiminny is mostly in the textmodality, while the voice-to-text transition ishandled by external services.Let's talk about the voice modality during tonight'sAl Chapter call - what it is, how we can utilize it atJiminny, current limitations, etc.To be honest, I'm also fairly new to this, so I'll spendsome time this afternoon preparing myself.d 1Message #ai-chapter+...
|
62324
|
NULL
|
NULL
|
NULL
|
|
60996
|
2173
|
32
|
2026-05-20T08:31:33.643926+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265893643_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorTOOISWindo PhostormVIewINavicarecodeLaravelKeractorTOOISWindowmelpFV faVsco.js( #12092 on JY-20613-allow-owner-role-on-team-setup~Proiect vc) User.onppnp aol Vz.onoMatchacuviycrmData.ong© Client.phps:phpphp customer api.phpphp embedded.phpnamespace lests reacure uruphp health.phpuse...php scim.phpphp uorotecterweo.ohophp weo,ono14 Ptinal class UserinvitationdTulest extends Testcasephp webhook.oho> scriotsuse Databaselransactions>D storageoublic function testcreate-rominvitat1ono*vo1dv testsv 7 FeatureM comnonent20 E** @var Invitation Sinvitation *^m ConsoleSinvitation = Invitation::factorv@)->create(П•MDTO'email' =>'[EMAIL]',E UserInvitationDTOTest.php'aroun id' => 1)> D Events>D Exceptions'team_id' => 1'role_id' => null,Local ChangesShelfConsole XLoav Changes 3 filesTJ g + → = Side-by-side viewer -Do not ignoreE.env.local appat885e531 contia/lloaaina.ono© JiminnyDebugCommand.php app/Console/Commandspnp logging.onp contigv Unversioned Files 9 files=.env.nikilocal app'Level' => env('LOG_LEVEL', 'info'),nath' => storade nath'loas/laravel.100')=.env.other app© CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/Ra tavicon.ico publicEids.txt apdTeraw so querv sal aoo@ SimulateWebhooksCommand.php app/Console/Commands/Crm/HubspotM+ WEBHOOK FILTERING IMPLEMENTATION.md aod"c) Userinvitationbtolest.onp= custom.log= laravel.log4 SF jiminny@localhost]& console [STAGING]« HS_local [jiminny@localhost]« console [PROD]© Clientrest.pnp© EditTeamRequest.phptii jiminny.users [PROD]©) Kernel.pnpA console (EU]© SoftPhoneManager.php X© TextMessagingService.phpv Q tii jiminny.users (EU]fiii crm_configurations (EU]Tinal readonly class sortrnonenanacer anolements conterenceranaderpublic const string COMPLIANCE PROMPT ACCEPT DIGTT S'1'A8V1Aoublic function constructiprivate ProviderRegistry ScrmProviderRegistry,private Webhook SurlGenerator,private ActivityService SactivityService,private LoggerInterface $logger.private TwilioClientBuilder StwilioClientBuilder,44 0 >public function create(VO\ConferenceManager\ActivityCreate $data): Models \Activity{...}private function create0utboundActivity(VO\ConferenceManager\SoftPhone0utboundCreate $data): Models ActiviSstartTime = microtime( as_float: true):current versionLunch • In 25 m100% S2Wed 20 May 11:31:33UserinvitationDTOTest vCascadeFixing TextRelayServicFix UserinvitationdteWARN No code coverage driver avallablere DTO.UserInvitationDTOTestfor ouner with necoye otdand voice role0.655+0 ..ThouchtsFixed the type mismatch error in testCreateFromInvitation by changing group id and team id from strings to integersin the factory call. All 4 tests now pass,lo ll ***" Code SWF.1.61 difterence'driver' => 'errorlog"'Level' => env('LOG_LEVEL', 'info'),'path" => storage_path('Logs/Laravel.Log')."drivert =>'single','path' => storage_path('logs/custom.log')"leveli =s envriind lEVElt Iinfar)APush reiectedorigin/JY-20613-allow-owner-role-on-team-setup.Show details in consoleW Windsurf Teams 23:1 UTF-8io 4 spaces...
|
NULL
|
-8305518267768923442
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorTOOISWindo PhostormVIewINavicarecodeLaravelKeractorTOOISWindowmelpFV faVsco.js( #12092 on JY-20613-allow-owner-role-on-team-setup~Proiect vc) User.onppnp aol Vz.onoMatchacuviycrmData.ong© Client.phps:phpphp customer api.phpphp embedded.phpnamespace lests reacure uruphp health.phpuse...php scim.phpphp uorotecterweo.ohophp weo,ono14 Ptinal class UserinvitationdTulest extends Testcasephp webhook.oho> scriotsuse Databaselransactions>D storageoublic function testcreate-rominvitat1ono*vo1dv testsv 7 FeatureM comnonent20 E** @var Invitation Sinvitation *^m ConsoleSinvitation = Invitation::factorv@)->create(П•MDTO'email' =>'[EMAIL]',E UserInvitationDTOTest.php'aroun id' => 1)> D Events>D Exceptions'team_id' => 1'role_id' => null,Local ChangesShelfConsole XLoav Changes 3 filesTJ g + → = Side-by-side viewer -Do not ignoreE.env.local appat885e531 contia/lloaaina.ono© JiminnyDebugCommand.php app/Console/Commandspnp logging.onp contigv Unversioned Files 9 files=.env.nikilocal app'Level' => env('LOG_LEVEL', 'info'),nath' => storade nath'loas/laravel.100')=.env.other app© CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/Ra tavicon.ico publicEids.txt apdTeraw so querv sal aoo@ SimulateWebhooksCommand.php app/Console/Commands/Crm/HubspotM+ WEBHOOK FILTERING IMPLEMENTATION.md aod"c) Userinvitationbtolest.onp= custom.log= laravel.log4 SF jiminny@localhost]& console [STAGING]« HS_local [jiminny@localhost]« console [PROD]© Clientrest.pnp© EditTeamRequest.phptii jiminny.users [PROD]©) Kernel.pnpA console (EU]© SoftPhoneManager.php X© TextMessagingService.phpv Q tii jiminny.users (EU]fiii crm_configurations (EU]Tinal readonly class sortrnonenanacer anolements conterenceranaderpublic const string COMPLIANCE PROMPT ACCEPT DIGTT S'1'A8V1Aoublic function constructiprivate ProviderRegistry ScrmProviderRegistry,private Webhook SurlGenerator,private ActivityService SactivityService,private LoggerInterface $logger.private TwilioClientBuilder StwilioClientBuilder,44 0 >public function create(VO\ConferenceManager\ActivityCreate $data): Models \Activity{...}private function create0utboundActivity(VO\ConferenceManager\SoftPhone0utboundCreate $data): Models ActiviSstartTime = microtime( as_float: true):current versionLunch • In 25 m100% S2Wed 20 May 11:31:33UserinvitationDTOTest vCascadeFixing TextRelayServicFix UserinvitationdteWARN No code coverage driver avallablere DTO.UserInvitationDTOTestfor ouner with necoye otdand voice role0.655+0 ..ThouchtsFixed the type mismatch error in testCreateFromInvitation by changing group id and team id from strings to integersin the factory call. All 4 tests now pass,lo ll ***" Code SWF.1.61 difterence'driver' => 'errorlog"'Level' => env('LOG_LEVEL', 'info'),'path" => storage_path('Logs/Laravel.Log')."drivert =>'single','path' => storage_path('logs/custom.log')"leveli =s envriind lEVElt Iinfar)APush reiectedorigin/JY-20613-allow-owner-role-on-team-setup.Show details in consoleW Windsurf Teams 23:1 UTF-8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60990
|
2172
|
21
|
2026-05-20T08:31:26.954581+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265886954_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7548221570517014107
|
5790451615831925272
|
click
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All...
|
60989
|
NULL
|
NULL
|
NULL
|
|
60989
|
2172
|
20
|
2026-05-20T08:31:24.590681+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265884590_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8143093784478180803
|
-9173319859618605809
|
click
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60988
|
2173
|
29
|
2026-05-20T08:31:24.173365+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265884173_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
2308126367194537848
|
-3047966025163132596
|
visual_change
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}...
|
60987
|
NULL
|
NULL
|
NULL
|
|
60948
|
2172
|
4
|
2026-05-20T08:30:33.441698+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265833441_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
visual_change
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60947
|
2173
|
4
|
2026-05-20T08:30:33.276071+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265833276_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
visual_change
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60946
|
2173
|
3
|
2026-05-20T08:30:30.918460+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265830918_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2673788577898438648
|
-8883980799901984945
|
click
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
60934
|
NULL
|
NULL
|
NULL
|
|
60945
|
2172
|
3
|
2026-05-20T08:30:30.923414+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265830923_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-8287066466843856937
|
-8809670306539792441
|
click
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes...
|
60933
|
NULL
|
NULL
|
NULL
|
|
60944
|
2173
|
2
|
2026-05-20T08:30:16.991648+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265816991_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60934
|
NULL
|
NULL
|
NULL
|
|
60943
|
2172
|
2
|
2026-05-20T08:30:11.682825+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265811682_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60933
|
NULL
|
NULL
|
NULL
|
|
60942
|
2173
|
1
|
2026-05-20T08:29:46.555828+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265786555_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60934
|
NULL
|
NULL
|
NULL
|
|
60941
|
2172
|
1
|
2026-05-20T08:29:41.337950+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265781337_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60933
|
NULL
|
NULL
|
NULL
|
|
60940
|
2173
|
0
|
2026-05-20T08:29:16.139881+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265756139_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60934
|
NULL
|
NULL
|
NULL
|
|
60939
|
2172
|
0
|
2026-05-20T08:29:10.967253+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265750967_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60933
|
NULL
|
NULL
|
NULL
|
|
60938
|
NULL
|
0
|
2026-05-20T08:28:45.689530+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265725689_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60934
|
NULL
|
NULL
|
NULL
|
|
60937
|
NULL
|
0
|
2026-05-20T08:28:40.593421+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265720593_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60933
|
NULL
|
NULL
|
NULL
|
|
60936
|
2171
|
19
|
2026-05-20T08:28:15.245366+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265695245_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60934
|
NULL
|
NULL
|
NULL
|
|
60935
|
2170
|
23
|
2026-05-20T08:28:10.169266+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265690169_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60933
|
NULL
|
NULL
|
NULL
|
|
60934
|
2171
|
18
|
2026-05-20T08:27:44.835307+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265664835_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60933
|
2170
|
22
|
2026-05-20T08:27:39.831381+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265659831_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60932
|
2171
|
17
|
2026-05-20T08:27:14.442586+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265634442_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
idle
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60930
|
NULL
|
NULL
|
NULL
|
|
60931
|
2170
|
21
|
2026-05-20T08:27:09.482795+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265629482_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2677828808183078404
|
5862518005962875416
|
click
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60929
|
NULL
|
NULL
|
NULL
|
|
60930
|
2171
|
16
|
2026-05-20T08:26:43.869338+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265603869_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Rerun 'PHPUnit: UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
Stop 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.12533244,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9494681,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-2356172230895241023
|
5790460411924947480
|
visual_change
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Rerun 'PHPUnit: UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
Stop 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60928
|
2171
|
15
|
2026-05-20T08:26:40.964245+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265600964_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","depth":3,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.11037234,"height":0.040702313},"on_screen":true,"value":"Push rejected\nPush to origin/JY-20613-allow-owner-role-on-team-setup was rejected","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.9018356,"width":0.028922873,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.915403,"width":0.102726065,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Show details in console","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.047872342,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8897439780028708708
|
-7153414712566901489
|
click
|
accessibility
|
NULL
|
Push rejected
Push to origin/JY-20613-allow-owner- Push rejected
Push to origin/JY-20613-allow-owner-role-on-team-setup was rejected
text/html
text/html
text/html
text/html
Show details in console...
|
60927
|
NULL
|
NULL
|
NULL
|
|
60920
|
2170
|
14
|
2026-05-20T08:26:00.611132+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265560611_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPPDOCKERO ₴1L SlackFileEditViewGoHistoryWindowHelpAPPDOCKERO ₴1Last login: Wed May 20 09:14:49 on ttys007DEV (-zsh)O ₴2.Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parentsPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parentslukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20915-add-domain-specific-emaidocker exec -it docker_lamp_1 bash-c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.irWhat's next:Try Docker Debug for seamless, persistent debugging tools in any container or image →Learn more at https://docs.docker.com/go/debug-cli/-it docker_lamp_1supervisorctlrestartjiminny-worker-processing-delayed: jiminny-worker-processing-delayed_00: stoppedworker-download:worker-download_00:stoppedjiminny-worker-processing-2:jiminny-worker-processing-2_00: stoppedjiminny-worker-processing-3:jiminny-worker-processing-3_00: stoppedjiminny-worker-processing-4:jiminny-worker-processing-4_00: stoppedjiminny-worker-processing-5:jiminny-worker-processing-5_00: stoppedworker-analytics:worker-analytics_00: stoppedworker-crm-update:worker-crm-update_00: stoppedworker-nudges:worker-nudges_00: stoppedworker-audio:worker-audio_00: stoppedjiminny-worker-processing-1:jiminny-worker-processing-1_00: stoppedworker:worker_00: stoppedworker-calendar:worker-calendar_00:stoppedworker-conferences:worker-conferences_00: stoppedworker-crm-sync:worker-crm-sync_00:stoppedworker-emails:worker-emails_00: stoppedworker-es-update:worker-es-update_00: stoppedartisan-schedule:artisan-schedule_00: stoppedartisan-schedule:artisan-schedule_00: startedjiminny-worker-processing-1:jiminny-worker-processing-1_00: startedjiminny-worker-processing-2:jiminny-worker-processing-2_00: startedjiminny-worker-processing-3:jiminny-worker-processing-3_00: startedjiminny-worker-processing-4:jiminny-worker-processing-4_00: startedjiminny-worker-processing-5:jiminny-worker-processing-5_00:jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: startedworker:worker_00: startedworker-analytics:worker-analytics_00:startedworker-audio:worker-audio_00: startedworker-calendar:worker-calendar_00:startedworker-conferences:worker-conferences_00: startedworker-crm-sync:worker-crm-sync_00: startedworker-crm-update:worker-crm-update_00: startedworker-download:worker-download_00:startedlahl•••EDHomeDMsActivityFilesLater..•More+→Jiminny ...# generar# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of jimi...0 Direct messages®. Aneliya AngelovaStoyan Tanevdo James Grahamã. Stefka StoyanovaP.. Nikolay Yankov% Galya Dimitrova€. Vasil Vasilev. Stoyan Tomov*: Todor StamatovMario GeorgievNikolay IvanovLukas Kovalik y... O::: AppsS Jira CloudToast| Lunch - in 35 m100% (C478•Wed 20 May 11:26:00Describe what you are looking forAneliya Angelova6 0• MessagesAdd canvas@ Files+и матчингаMonday, May 18th ~ ЛЖИhttps://us-east-2.console.aws.amazon.com/cloudwatch/home?region=us-east-2#logsV2:logs-insights$3FqueryDetailS(...IntlDs~(~'All)~ queryBy~'allLogGroups)Lukas Kovalik 5:10 PMсуперутре ще го деплойна тогаваToday ~Aneliya Angelova 11:11 AMЗдрасти Лукашhttps://app.circleci.com/pipelines/github/jiminny/apр/58598/workflows/27f2b2f4-2847-47f0-a79d-9e313cbe278e/jobs/890111фейлва някакъв тестJY-20613-allow-owner-role-on-team-setupLukas Kovalik 11:20 AMздрасти, уж е минал, севга щевид дали нещотрябва да се правиLukas Kovalik 11:25 AMсмених го малко, сега би трябвало да мине вечеMessage Aneliya Angelova+..•...
|
NULL
|
-569634743153243024
|
NULL
|
click
|
ocr
|
NULL
|
SlackFileEditViewGoHistoryWindowHelpAPPDOCKERO ₴1L SlackFileEditViewGoHistoryWindowHelpAPPDOCKERO ₴1Last login: Wed May 20 09:14:49 on ttys007DEV (-zsh)O ₴2.Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parentsPoetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parentslukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20915-add-domain-specific-emaidocker exec -it docker_lamp_1 bash-c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.irWhat's next:Try Docker Debug for seamless, persistent debugging tools in any container or image →Learn more at https://docs.docker.com/go/debug-cli/-it docker_lamp_1supervisorctlrestartjiminny-worker-processing-delayed: jiminny-worker-processing-delayed_00: stoppedworker-download:worker-download_00:stoppedjiminny-worker-processing-2:jiminny-worker-processing-2_00: stoppedjiminny-worker-processing-3:jiminny-worker-processing-3_00: stoppedjiminny-worker-processing-4:jiminny-worker-processing-4_00: stoppedjiminny-worker-processing-5:jiminny-worker-processing-5_00: stoppedworker-analytics:worker-analytics_00: stoppedworker-crm-update:worker-crm-update_00: stoppedworker-nudges:worker-nudges_00: stoppedworker-audio:worker-audio_00: stoppedjiminny-worker-processing-1:jiminny-worker-processing-1_00: stoppedworker:worker_00: stoppedworker-calendar:worker-calendar_00:stoppedworker-conferences:worker-conferences_00: stoppedworker-crm-sync:worker-crm-sync_00:stoppedworker-emails:worker-emails_00: stoppedworker-es-update:worker-es-update_00: stoppedartisan-schedule:artisan-schedule_00: stoppedartisan-schedule:artisan-schedule_00: startedjiminny-worker-processing-1:jiminny-worker-processing-1_00: startedjiminny-worker-processing-2:jiminny-worker-processing-2_00: startedjiminny-worker-processing-3:jiminny-worker-processing-3_00: startedjiminny-worker-processing-4:jiminny-worker-processing-4_00: startedjiminny-worker-processing-5:jiminny-worker-processing-5_00:jiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: startedworker:worker_00: startedworker-analytics:worker-analytics_00:startedworker-audio:worker-audio_00: startedworker-calendar:worker-calendar_00:startedworker-conferences:worker-conferences_00: startedworker-crm-sync:worker-crm-sync_00: startedworker-crm-update:worker-crm-update_00: startedworker-download:worker-download_00:startedlahl•••EDHomeDMsActivityFilesLater..•More+→Jiminny ...# generar# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of jimi...0 Direct messages®. Aneliya AngelovaStoyan Tanevdo James Grahamã. Stefka StoyanovaP.. Nikolay Yankov% Galya Dimitrova€. Vasil Vasilev. Stoyan Tomov*: Todor StamatovMario GeorgievNikolay IvanovLukas Kovalik y... O::: AppsS Jira CloudToast| Lunch - in 35 m100% (C478•Wed 20 May 11:26:00Describe what you are looking forAneliya Angelova6 0• MessagesAdd canvas@ Files+и матчингаMonday, May 18th ~ ЛЖИhttps://us-east-2.console.aws.amazon.com/cloudwatch/home?region=us-east-2#logsV2:logs-insights$3FqueryDetailS(...IntlDs~(~'All)~ queryBy~'allLogGroups)Lukas Kovalik 5:10 PMсуперутре ще го деплойна тогаваToday ~Aneliya Angelova 11:11 AMЗдрасти Лукашhttps://app.circleci.com/pipelines/github/jiminny/apр/58598/workflows/27f2b2f4-2847-47f0-a79d-9e313cbe278e/jobs/890111фейлва някакъв тестJY-20613-allow-owner-role-on-team-setupLukas Kovalik 11:20 AMздрасти, уж е минал, севга щевид дали нещотрябва да се правиLukas Kovalik 11:25 AMсмених го малко, сега би трябвало да мине вечеMessage Aneliya Angelova+..•...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60919
|
2171
|
12
|
2026-05-20T08:26:01.706981+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265561706_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
1 file committed
JY-20613 change test
text/html
te 1 file committed
JY-20613 change test
text/html
text/html
text/html
Edit Commit Message…
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"","depth":2,"bounds":{"left":0.6449468,"top":0.35993615,"width":0.06948138,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":2,"bounds":{"left":0.6449468,"top":0.39185953,"width":0.06948138,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1 file committed","depth":2,"bounds":{"left":0.8753325,"top":0.91300875,"width":0.100398935,"height":0.013567438},"on_screen":true,"role_description":"text"},{"role":"AXTextField","text":"JY-20613 change test","depth":3,"bounds":{"left":0.8753325,"top":0.92897046,"width":0.11037234,"height":0.013567438},"on_screen":true,"value":"JY-20613 change test","help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"bounds":{"left":0.8753325,"top":0.92897046,"width":0.045545213,"height":0.013567438},"on_screen":true,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"text/html","depth":4,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Edit Commit Message…","depth":2,"bounds":{"left":0.8753325,"top":0.9481245,"width":0.048204787,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.13530585,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.0,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6527947189970166765
|
5862236805864071704
|
click
|
accessibility
|
NULL
|
1 file committed
JY-20613 change test
text/html
te 1 file committed
JY-20613 change test
text/html
text/html
text/html
Edit Commit Message…
Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60906
|
2170
|
6
|
2026-05-20T08:25:09.529315+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265509529_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1567583387871419915
|
10485828285863874
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
SlackFileEditViewGoHistoryWindowHelpAPPDOCKERO ₴1Last login: Wed May 20 09:14:49on ttys007DEV (-zsh)O 82Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parentsPoetry could notfind a pyproject.tomlfile in /Users/lukas/jiminny/app or its parentslukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20915-add-domain-specific-emaidocker exec -it docker_lamp_1 bash-c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.irWhat's next:Try Docker Debug for seamless, persistentdebugging tools in any container or image →Learn moreat https://docs.docker.com/go/debug-cli/dockerexec-it docker_lamp_1supervisorctlrestartalljiminny-worker-processing-delayed: jiminny-worker-processing-delayed_00: stoppedworker-download:worker-download_00:stoppedjiminny-worker-processing-2:jiminny-worker-processing-2_00: stoppedjiminny-worker-processing-3:jiminny-worker-processing-3_00: stoppedjiminny-worker-processing-4:jiminny-worker-processing-4_00: stoppedjiminny-worker-processing-5:jiminny-worker-processing-5_00: stoppedworker-analytics:worker-analytics_00: stoppedworker-crm-update:worker-crm-update_00: stoppedworker-nudges:worker-nudges_00: stoppedworker-audio:worker-audio_00: stoppedjiminny-worker-processing-1:jiminny-worker-processing-1_00: stoppedworker:worker_00: stoppedworker-calendar:worker-calendar_00:stoppedworker-conferences:worker-conferences_00: stoppedworker-crm-sync:worker-crm-sync_00:stoppedworker-emails:worker-emails_00: stoppedworker-es-update:worker-es-update_00: stoppedartisan-schedule:artisan-schedule_00: stoppedartisan-schedule:artisan-schedule_00: startedjiminny-worker-processing-1:jiminny-worker-processing-1_00: startedjiminny-worker-processing-2:jiminny-worker-processing-2_00: startedjiminny-worker-processing-3:jiminny-worker-processing-3_00: startedjiminny-worker-processing-4:jiminny-worker-processing-4_00: startedjiminny-worker-processing-5:jiminny-worker-processing-5_00:startedjiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: startedworker:worker_00: startedworker-analytics:worker-analytics_00:startedworker-audio:worker-audio_00: startedworker-calendar:worker-calendar_00:startedworker-conferences:worker-conferences_00: startedworker-crm-sync:worker-crm-sync_00: startedworker-crm-update:worker-crm-update_00: startedworker-download:worker-download_00:startedHomeDMsActivityFilesLater..•More+(ahlLunch - in 35 m100% <78•ED→Jiminny... v# generar# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of jimi...Wed 20 May 11:25:09Describe what you are looking forJira CloudHomeMessages• Direct messagese. Aneliya AngelovaStoyan Tanevdo James Grahamã. Stefka StoyanovaP. Nikolay Yankov% Galya Dimitrova€. Vasil Vasilev. Stoyan Tomov*:Todor StamatovMario GeorgievNikolay IvanovLukas Kovalik y... OAboutv 2 new messagesTrying to enable a report that has a deletedprompt:CommentMore actions...Today ~NewJira Cloud APP 11:20 AMNikolay Yankov commented on a Story you areassigned toJY-20615 Notify the user if a SS is deleted but isused in AJ ReportThe new modal:CommentMore actions...Nikolay Yankov edited a comment on a Storyyou are assigned toJY-20615 Notify the user if a SS is deleted but isused in AJ ReportThe new modal:CommentMore actions...i: AppsS Jira CloudToastMessage Jira Cloud+...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60905
|
2171
|
6
|
2026-05-20T08:25:10.082955+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265510082_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.13131648,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.40325797,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.41223404,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.4195479,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.0,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
44428070714842462
|
-2903850562209369772
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60904
|
2171
|
5
|
2026-05-20T08:25:06.995346+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265506995_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.13131648,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1421024576491178512
|
-8780532123125446714
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
PhostormVIewINavicarecodeLaravelKeractorWindowmelpFV faVsco.js#12092 on JY-20613-allow-owner-role-on-team-setupProiect© UserInvitationDTOTest.php X© MatchActivityCrmData.phppnp aol Vz.onoJiminnyDeougcommana.phpc) ClientTest.pnptinal class userinviraclonululest excends lestuasephp customer api.phpphp embedded.phppublic tunction testcreaterrominvitationO: vo1dphp health.phpphp scim.php/** @var Invitation Sinvitation */Sinvitation = Invitation:: factoryO->create(Пphp uorotecterweo.ohophp weo.ohoaroun 1o => 1php webhook.oho> scriots'team_id' => 1'role_id' => null,>D storage'crm_required' => 1v tests'is owner'v 7 Feature=>0M comnonentConsole•MотоI/** @var User Suser */Susen = lIsen: •factonvo->crpate@•e) UserinvitationD lolest.onp/** @var Role $userRole */> D EventsSusenRole = Role: •wheneName & value: IIsen• •ROI F RECORNER)->fincto•> D ExceptionsLocal ChangesShelfConsolev Changes 4 files, updating.TJ g + → = Side-by-side viewer -Do not ignoreE.env.local appn098eeaa0 contia/loaaina.oho© JiminnyDebugCommand.php app/Console/Commandspnp logging.onp contigUserinvitationDTOTest.php tests/Feature/DTOv Unversioned Files 9 files'Level' => env('LOG_LEVEL', 'info'),nath' => storade nath'loas/laravel.100').env.nikilocal app=.env.other app© CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/Rai tavicon.ico public=lds,txi a0dTeraw sol querv sal aooC)SimulateWebhooksCommand.on aop/Console/Commands/Crm/HubspotM. WEBHOOK FILTERING_IMPLEMENTATION.md apd© EditTeamRequest.php= custom.log= laravel.log4 SF jiminny@localhost]& console [STAGING]« HS_local [jiminny@localhost]& console [Pkol)iii jiminny.users [PROD]©) Kernel.pnpA console (EU]© SoftPhoneManager.php XTextwessagingservice.onptid jiminny.users (EU]fiii crm_configurations (EU]final readonly class SoftPhoneManager implements ConferenceManagerUserinvitationDTOTest xVOEKO:v Test ResultsA8V1 ALunch • In somCascadeFixing TextRelayServiFix Userinvitationdte100% S2• Wed 20 May 11:25:06UserinvitationDTOTest vWARN No code coverage driver avallablePASS TestslFeature DT0.UserInvitationDTOTest+0 ..D393 ms0.05steamid from strinas to integersv 4 tests passed 4 tests total, 393 ms[docker-compose://[/Users/Lukas/jiminny/infrastructure/dev/docker/docker-compose.yml]:lamp/]:php ./vendor/bin/phpunit --configuration phpunit.xml --filter Tests\\Feature\\DTO\\Us ~Testing started at 11:25 ..WARN (00001 /Users/lukas/jiminny/infrastructure/dev/docker/docker-compose.yml: the attribute 'version' is obsolete, it will be ignored, please remove it to avoid potential confusiPHPUnit 11.5.55 by Sebastian Bergmann and contributors.Duntimo.pHp 9720Confiquration: /home/aiminny/.ohounhr.xmlTime: 00:01.956, Memory: 62.00 MBThere was 1 PHPUnit test runner warning:1) No code coverage driver availableOK, but there were issues!ests: 4. Assertions: 24. PHPUnat Warninos:Process finished with exit code 11differenceAAAOW Windsurf Teams 23:1 UTF-8 P 4 spaces...
|
60902
|
NULL
|
NULL
|
NULL
|
|
60903
|
2170
|
5
|
2026-05-20T08:25:06.970449+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265506970_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-4244645190263702205
|
-8708764808067708094
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
SlackFileEditViewGoHistoryWindowHelpAPPDOCKER₴81Last login: Wed May 20 09:14:49on ttys007DEV (-zsh)O 82Poetry could not find a pyproject.toml file in /Users/lukas/jiminny/app or its parentsPoetry could notfind a pyproject.tomlfile in /Users/lukas/jiminny/app or its parentslukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/Jiminny/app (JY-20915-add-domain-specific-emaidocker exec -it docker_lamp_1 bash-c "mv /usr/local/etc/php/conf.d/xdebug.ini ~/xdebug.irWhat's next:Try Docker Debug for seamless, persistentdebugging tools in any container or image →Learn moreat https://docs.docker.com/go/debug-cli/dockerexec-it docker_lamp_1supervisorctlrestartalljiminny-worker-processing-delayed: jiminny-worker-processing-delayed_00: stoppedworker-download:worker-download_00:stoppedjiminny-worker-processing-2:jiminny-worker-processing-2_00: stoppedjiminny-worker-processing-3:jiminny-worker-processing-3_00: stoppedjiminny-worker-processing-4:jiminny-worker-processing-4_00: stoppedjiminny-worker-processing-5:jiminny-worker-processing-5_00: stoppedworker-analytics:worker-analytics_00: stoppedworker-crm-update:worker-crm-update_00: stoppedworker-nudges:worker-nudges_00: stoppedworker-audio:worker-audio_00: stoppedjiminny-worker-processing-1:jiminny-worker-processing-1_00: stoppedworker:worker_00: stoppedworker-calendar:worker-calendar_00:stoppedworker-conferences:worker-conferences_00: stoppedworker-crm-sync:worker-crm-sync_00:stoppedworker-emails:worker-emails_00: stoppedworker-es-update:worker-es-update_00: stoppedartisan-schedule:artisan-schedule_00: stoppedartisan-schedule:artisan-schedule_00: startedjiminny-worker-processing-1:jiminny-worker-processing-1_00: startedjiminny-worker-processing-2:jiminny-worker-processing-2_00: startedjiminny-worker-processing-3:jiminny-worker-processing-3_00: startedjiminny-worker-processing-4:jiminny-worker-processing-4_00: startedjiminny-worker-processing-5:jiminny-worker-processing-5_00:startedjiminny-worker-processing-delayed:jiminny-worker-processing-delayed_00: startedworker:worker_00: startedworker-analytics:worker-analytics_00:startedworker-audio:worker-audio_00: startedworker-calendar:worker-calendar_00:startedworker-conferences:worker-conferences_00: startedworker-crm-sync:worker-crm-sync_00: startedworker-crm-update:worker-crm-update_00: startedworker-download:worker-download_00:startedHomeDMsActivityFilesLater..•More+(ahlLunch - in 35 m100% <78•ED→Jiminny... v# generar# happy_birthday# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of jimi...Wed 20 May 11:25:06Describe what you are looking forJira CloudHomeMessagesAbout• Direct messagese. Aneliya AngelovaStoyan Tanevdo James Grahamã. Stefka StoyanovaP. Nikolay Yankov% Galya Dimitrova€. Vasil Vasilev. Stoyan Tomov*:Todor StamatovMario GeorgievNikolay IvanovLukas Kovalik y... Ov 2 new messagesTrying to enable a report that has a deletedprompt:CommentMore actions...Today ~NewJira Cloud APP 11:20 AMNikolay Yankov commented on a Story you areassigned toJY-20615 Notify the user if a SS is deleted but isused in AJ ReportThe new modal:CommentMore actions...Nikolay Yankov edited a comment on a Storyyou are assigned toJY-20615 Notify the user if a SS is deleted but isused in AJ ReportThe new modal:CommentMore actions...i: AppsS Jira CloudToastMessage Jira Cloud+...
|
60901
|
NULL
|
NULL
|
NULL
|
|
60902
|
2171
|
4
|
2026-05-20T08:25:02.447192+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265502447_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Rerun 'PHPUnit: UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
Stop 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.13131648,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Rerun 'PHPUnit: UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Stop 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9494681,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.40325797,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.41223404,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.4195479,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.0,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-104932467384652312
|
5790179211826143768
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Rerun 'PHPUnit: UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
Stop 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60901
|
2170
|
4
|
2026-05-20T08:24:59.461311+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265499461_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1434938327193753565
|
5862518280840782360
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60900
|
2171
|
3
|
2026-05-20T08:24:58.759212+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265498759_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.13131648,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.40325797,"top":0.10055866,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.41223404,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.4195479,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"bounds":{"left":0.15990691,"top":0.09736632,"width":0.2662899,"height":0.8954509},"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
44428070714842462
|
-2903850562209369772
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}...
|
60898
|
NULL
|
NULL
|
NULL
|
|
60899
|
2170
|
3
|
2026-05-20T08:24:58.769345+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265498769_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1281172772322589695
|
5862236805864071704
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project...
|
60897
|
NULL
|
NULL
|
NULL
|
|
60898
|
2171
|
2
|
2026-05-20T08:24:52.135307+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265492135_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.13131648,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1434938327193753565
|
5862518280840782360
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60897
|
2170
|
2
|
2026-05-20T08:24:52.021447+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265492021_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1434938327193753565
|
5862518280840782360
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
60896
|
2171
|
1
|
2026-05-20T08:24:21.638444+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265461638_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.13131648,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-521399978491441721
|
-2904413787040698020
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error...
|
60888
|
NULL
|
NULL
|
NULL
|
|
60895
|
2170
|
1
|
2026-05-20T08:24:21.638307+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265461638_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"}]...
|
5067029653404846077
|
-2904413786772262564
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1...
|
60889
|
NULL
|
NULL
|
NULL
|
|
60894
|
2171
|
0
|
2026-05-20T08:24:16.928248+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265456928_m2.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.13131648,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.83610374,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"bounds":{"left":0.85139626,"top":0.019952115,"width":0.06416223,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.006981383,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.7024601,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.7124335,"top":0.12529927,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.72140956,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.7287234,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1434938327193753565
|
5862518280840782360
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60888
|
NULL
|
NULL
|
NULL
|
|
60893
|
2170
|
0
|
2026-05-20T08:24:12.585483+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-20/1779 /Users/lukas/.screenpipe/data/data/2026-05-20/1779265452585_m1.jpg...
|
PhpStorm
|
faVsco.js – UserInvitationDTOTest.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12092 on JY-20613-allow-owner-role-on-team-setup, menu","depth":5,"on_screen":true,"help_text":"Pull request #12092 exists for current branch JY-20613-allow-owner-role-on-team-setup, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"UserInvitationDTOTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'UserInvitationDTOTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.02111111},"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.014583333,"height":0.025555555},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Tests\\Feature\\DTO;\n\nuse Illuminate\\Foundation\\Testing\\DatabaseTransactions;\nuse Jiminny\\DTO\\Invitation\\UserInvitationDTO;\nuse Jiminny\\Http\\Requests\\Settings\\Teams\\CreateTeamRequest;\nuse Jiminny\\Models\\Invitation;\nuse Jiminny\\Models\\Role;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\nuse Tests\\TestCase;\n\nfinal class UserInvitationDTOTest extends TestCase\n{\n use DatabaseTransactions;\n\n public function testCreateFromInvitation(): void\n {\n /** @var Invitation $invitation */\n $invitation = Invitation::factory()->create([\n 'email' => 'Test@gmail.com',\n 'group_id' => 1,\n 'team_id' => 1,\n 'role_id' => null,\n 'crm_required' => 1,\n 'is_owner' => 0,\n ]);\n\n /** @var User $user */\n $user = User::factory()->create();\n /** @var Role $userRole */\n $userRole = Role::whereName(User::ROLE_RECORDER)->first();\n\n $invitation->roles()->sync($userRole);\n\n $dto = UserInvitationDTO::fromInvitation($invitation, $user);\n\n self::assertSame('test@gmail.com', $dto->email);\n self::assertSame(1, $dto->groupId);\n self::assertSame(1, $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertSame([$userRole->getId()], $dto->roleIds);\n self::assertSame($user, $dto->currentUser);\n }\n\n public function testForOwner(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderRole */\n $recorderRole = Role::whereName(User::ROLE_RECORDER)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithRecorderAndVoiceRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'recorder_and_voice',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $recorderAndVoiceRole */\n $recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n\n public function testForOwnerWithAnalystRole(): void\n {\n /** @var Team $team */\n $team = Team::factory()->create();\n\n $request = new CreateTeamRequest([\n 'owner_email' => 'admin@jiminny.com',\n 'owner_role' => 'analyst',\n ]);\n\n /** @var User $currentUser */\n $currentUser = User::factory()->create();\n $request->setUserResolver(static fn () => $currentUser);\n\n $dto = UserInvitationDTO::forOwner($request, $team);\n\n /** @var Role $analystRole */\n $analystRole = Role::whereName(User::ROLE_ANALYST)->first();\n /** @var Role $adminRole */\n $adminRole = Role::whereName(User::ROLE_ADMIN)->first();\n\n self::assertSame('admin@jiminny.com', $dto->email);\n self::assertNull($dto->groupId);\n self::assertSame($team->getId(), $dto->teamId);\n self::assertTrue($dto->crmRequired);\n self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);\n self::assertSame($currentUser, $dto->currentUser);\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\n\nuse Illuminate\\Database\\Eloquent;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\ElasticSearch\\Model\\Observer;\nuse Jiminny\\Component\\Twilio\\Conference\\ConferenceManager;\nuse Jiminny\\Component\\Twilio\\Exception\\OutOfBoundsException;\nuse Jiminny\\Component\\Twilio\\Exception\\UnexpectedValueException;\nuse Jiminny\\Component\\Twilio\\Resolver;\nuse Jiminny\\Component\\Twilio\\VO;\nuse Jiminny\\Component\\UrlGenerator\\Webhook;\nuse Jiminny\\Events;\nuse Jiminny\\Exceptions\\LogicException;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Services\\ActivityService;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Jiminny\\Services\\Crm\\ProviderRegistry;\nuse Jiminny\\Services\\Telephony\\TwilioClientBuilder;\nuse Psr\\Log\\LoggerInterface;\nuse Twilio\\Exceptions\\ConfigurationException;\nuse Twilio\\Exceptions\\RestException;\nuse Twilio\\Exceptions\\TwilioException;\nuse Twilio\\TwiML;\n\nfinal readonly class SoftPhoneManager implements ConferenceManager\n{\n public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;\n public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';\n\n public function __construct(\n private ProviderRegistry $crmProviderRegistry,\n private Webhook $urlGenerator,\n private ActivityService $activityService,\n private LoggerInterface $logger,\n private TwilioClientBuilder $twilioClientBuilder,\n ) {\n }\n\n public function create(VO\\ConferenceManager\\ActivityCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n 'instanceof' => get_class($data),\n ]);\n\n $activity = Observer::skipEvents(function () use ($data) {\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundCreate) {\n $activity = $this->createInboundActivity($data);\n } elseif ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundCreate) {\n $activity = $this->createOutboundActivity($data);\n } else {\n throw new LogicException(sprintf(\n 'Unknown softphone implementation: %s',\n get_class($data)\n ));\n }\n\n return $activity;\n });\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'instanceof' => get_class($data),\n 'activity' => $activity->id_string,\n ]);\n\n return $activity;\n }\n\n private function createOutboundActivity(VO\\ConferenceManager\\SoftPhoneOutboundCreate $data): Models\\Activity\n {\n $startTime = microtime(true);\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n $now = now();\n $user = $data->getUser();\n $team = $user->getTeam();\n $device = $data->getDevice();\n $phoneNumber = $data->getPhoneNumber();\n $route = $data->getPhoneRoute();\n\n if (! $this->activityService->teamCanDial($team, $phoneNumber)) {\n $region = getRegionByNumber($phoneNumber, $user->getCountryCode());\n\n throw new LogicException(sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? 'this number'\n ));\n }\n\n $lead = null;\n $account = null;\n $contact = null;\n $stage = null;\n $opportunity = null;\n\n // Setup services.\n $crmService = $this->crmProviderRegistry->get($team->crm->provider);\n\n if (! $user->isCrmRequired()) {\n $crmService->setUser($user->team->owner);\n } else {\n $crmService->setUser($user);\n }\n\n $prospectId = $data->getProspectId();\n\n try {\n if ($prospectId !== null) {\n $crmStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [\n 'prospect_id' => $prospectId,\n 'object_type' => $data->getObjectType(),\n ]);\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(\n $prospectId,\n $data->getObjectType()\n );\n $this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),\n 'found_lead' => $lead !== null,\n 'found_account' => $account !== null,\n 'found_opportunity' => $opportunity !== null,\n 'found_contact' => $contact !== null,\n ]);\n\n // Try to attach a contact if we have an opportunity.\n if ($opportunity !== null && $contact === null) {\n $matchStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');\n [, , , $contact, ] = $crmService->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n $this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),\n 'found_contact' => $contact !== null,\n ]);\n }\n } else {\n $decorator = resolve(CachedCrmServiceDecorator::class);\n $decorator->setCrmService($crmService);\n\n [$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(\n $data->getPhoneNumber(),\n $data->getRawPhoneNumber(),\n $user->getId()\n );\n }\n } catch (\\Throwable $throwable) {\n $this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.\n }\n\n if ($data->hasProspectStage()) {\n /** @var Models\\Stage $stage */\n $stage = $team->crm->stages()->uuid($data->getProspectStage());\n }\n\n $dbStart = microtime(true);\n $this->logger->info('[SoftPhoneManager] Starting activity creation in DB');\n $activity = $user->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'device_id' => $device->id ?? null,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'value' => $opportunity ? $opportunity->value : null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_PENDING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),\n 'scheduled_start_time' => $now,\n ]);\n $this->logger->info('[SoftPhoneManager] Activity created in DB', [\n 'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),\n 'activity_id' => $activity->id ?? null,\n ]);\n\n if (! $activity instanceof Models\\Activity) {\n throw new LogicException('Activity model expected');\n }\n\n $regionId = null;\n if ($user->hasRegionId()) {\n $regionId = $user->getRegionId();\n } elseif ($data->hasRegion()) {\n $regionId = $data->getRegion()->getId();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $regionId,\n 'is_muted' => false,\n ]);\n\n $phoneRouteResolver = new Resolver\\PhoneRouteResolver($user, $route);\n $phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();\n $connectionType = $phoneRouteResolver->getConnectionType();\n $callerId = $this->determineUserCallerId($user);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'user_id' => $user->id,\n 'email' => $user->email,\n 'name' => $user->name,\n 'phone_number' => $phoneRouteResolver->mustUsePSTN()\n ? $phoneNumberOrigin\n : $callerId,\n ]);\n\n $caller->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',\n 'phone' => $callerId,\n 'type' => $connectionType,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $phoneNumber,\n 'name' => $activity->prospect_name,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'phone' => $phoneNumber,\n 'direction' => 'outbound-api',\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $activity->refresh();\n\n $this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [\n 'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),\n 'activity_id' => $activity->id_string,\n ]);\n $this->logger->debug(__METHOD__ . ': done', [\n 'data' => $data,\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n return $activity;\n }\n\n private function createInboundActivity(VO\\ConferenceManager\\SoftPhoneInboundCreate $data): Models\\Activity\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $roomOwner = $data->getUser();\n $team = $roomOwner->getTeam();\n $phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();\n $callerId = $data->getCallerId();\n $callerCallSid = $data->getCallSid();\n\n $now = now();\n\n if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {\n $region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());\n\n throw new LogicException(\n sprintf(\n 'Sorry, dialing %s is not available with your subscription.'\n . ' Please contact your manager to enable additional destinations.',\n $region ?? ' this number',\n ),\n );\n }\n\n $lead = $account = $opportunity = $contact = $stage = null;\n\n try {\n $crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);\n $crmService->setUser($roomOwner);\n\n [$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(\n $callerId,\n null,\n $roomOwner->getId()\n );\n } catch (\\Throwable $throwable) {\n $this->logger->error(__METHOD__ . ': crm exception', [\n 'exception' => $throwable,\n ]);\n // maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening\n }\n\n /** @var Models\\Activity $activity */\n $activity = $roomOwner->activities()->create([\n 'type' => Models\\Activity::TYPE_SOFTPHONE_INBOUND,\n 'provider' => Models\\Activity::PROVIDER_TWILIO,\n 'crm_configuration_id' => $team->crm_id,\n 'lead_id' => $lead->id ?? null,\n 'account_id' => $account->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => Models\\Activity::STATUS_RINGING,\n 'recording_state' => Models\\Activity::RECORDING_OFF,\n 'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),\n 'scheduled_start_time' => $now,\n ]);\n\n /** @var Models\\Session $session */\n $session = $activity->sessions()->create([\n 'type' => Models\\Session::TYPE_DIALER,\n 'region_id' => $roomOwner->getRegionId(),\n 'is_muted' => false,\n ]);\n\n /** @var Models\\Participant $caller */\n $caller = $activity->participants()->create([\n 'lead_id' => $activity->lead_id ?? null,\n 'contact_id' => $activity->contact_id ?? null,\n 'phone_number' => $callerId,\n 'name' => $activity->prospect_name,\n 'enter_time' => $now,\n ]);\n\n $caller->connections()->create([\n 'telephony_provider_id' => $callerCallSid,\n 'session_id' => $session->getId(),\n 'phone' => $callerId,\n 'direction' => 'inbound',\n 'type' => Connection::TYPE_PSTN,\n 'opened_at' => $now,\n ]);\n\n /** @var Models\\Participant $callee */\n $callee = $activity->participants()->create([\n 'user_id' => $roomOwner->id,\n 'email' => $roomOwner->email,\n 'name' => $roomOwner->name,\n 'phone_number' => $phoneNumber,\n ]);\n\n $callee->connections()->create([\n 'session_id' => $session->getId(),\n 'direction' => 'outbound-api',\n 'phone' => $phoneNumber,\n 'type' => Connection::TYPE_PSTN,\n ]);\n\n $activity->update([\n 'from_participant_id' => $caller->id,\n 'to_participant_id' => $callee->id,\n ]);\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'callSid' => $data->getCallSid(),\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n 'caller' => $caller->id_string,\n 'callee' => $callee->id_string,\n ]);\n\n $activity->refresh();\n\n return $activity;\n }\n\n private function getVoiceRecordingReasonCode(Models\\Team $team, string $phoneNumber): int\n {\n $this->logger->debug(__METHOD__, [\n 'team' => $team->id_string,\n 'phone' => $phoneNumber,\n ]);\n\n if (\n ! $team->hasComplianceModeRecordingPrompt()\n && ! $team->hasComplianceModeRecordingRestrictToOneSide()\n && ! $team->hasComplianceModeRecordingRestrictRecording()\n ) {\n return 0;\n }\n\n /** @var Models\\VoiceConsentPrefix|null $voiceConsentPolicy */\n $voiceConsentPolicy = Models\\VoiceConsentPrefix::query()\n ->whereRaw(\"? LIKE CONCAT(destination_prefix, '%')\", [$phoneNumber])\n ->where(static function (Eloquent\\Builder $builder) use ($team): void {\n $builder\n ->whereNull('team_id')\n ->orWhere('team_id', $team->getId());\n })\n ->orderByDesc('team_id')\n ->first();\n\n if (! $voiceConsentPolicy instanceof Models\\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {\n return 0;\n }\n\n if ($team->hasComplianceModeRecordingPrompt()) {\n // This number requires consent from the other party before recording the call.\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;\n }\n\n if ($team->hasComplianceModeRecordingRestrictToOneSide()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;\n }\n\n if ($team->hasComplianceModeRecordingRestrictRecording()) {\n return Models\\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;\n }\n\n return 0;\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOrganiser(Models\\Activity $activity): void\n {\n $this->logger->debug(__METHOD__, [\n 'activity' => $activity->id_string,\n ]);\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $canBeDialed =\n $activity->isTypeSoftPhone()\n && ! $activity->hasStarted()\n && $activity->isPending()\n && $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->exists();\n\n if (! $canBeDialed) {\n throw new OutOfBoundsException('Cannot make an outbound call to this activity.');\n }\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->where('type', '=', Connection::TYPE_PSTN)\n ->whereNull('opened_at')\n ->firstOrFail();\n\n /** @var Models\\Session $session */\n $session = $organiserConnection->getSession();\n\n $twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ]);\n\n // What number will the organizer see as caller id\n $callerId = $roomOwner->getSoftPhoneNumber();\n\n if ($callerId === null) {\n $callee = $activity->participants()->whereNull('user_id')->first();\n // This is a bridge call. We make it look like the callee is calling the organizer\n $callerId = $callee->phone_number;\n }\n\n // What number will the prospect see as caller id\n $calleeId = $organiser->getPhoneNumber();\n\n $this->logger->debug(__METHOD__ . ': twilio call', [\n 'calleeId' => $calleeId,\n 'callerId' => $callerId,\n ]);\n\n $twilioParticipant = $twilio->createParticipantInstance(\n $session->id_string,\n $callerId,\n $calleeId,\n $twilioConferenceSettings\n );\n\n $organiserConnection\n ->setTelephonyProviderId($twilioParticipant->callSid)\n ->save();\n\n $session\n ->setTelephonyProviderId($twilioParticipant->conferenceSid)\n ->save();\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function dialCoach(Models\\Activity $activity, Models\\User $user): void\n {\n throw new \\LogicException('Not implemented yet');\n }\n\n /**\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function terminate(VO\\ConferenceManager\\ActivityTerminate $data): void\n {\n if (! $data instanceof VO\\ConferenceManager\\SoftphoneTerminate) {\n throw new UnexpectedValueException(sprintf(\n 'Expecting an instance of \"%s\", but \"%s\" given.',\n VO\\ConferenceManager\\SoftphoneTerminate::class,\n \\get_class($data)\n ));\n }\n\n $this->logger->debug(__METHOD__, [\n 'activity' => $data->getActivity()->id_string,\n 'user' => $data->getUser()->id_string,\n ]);\n\n $activity = $data->getActivity();\n $roomOwner = $activity->getUser();\n\n $isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();\n\n if ($isCancelling) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n }\n\n /** @var Models\\Session $session */\n $session = $activity->sessions->first();\n\n /** @var Models\\Participant $organiser */\n $organiser = $activity->organizer;\n\n /** @var Connection $organiserConnection */\n $organiserConnection = $organiser->connections()\n ->whereIn('type', [\n Connection::TYPE_WEBRTC,\n Connection::TYPE_PSTN,\n ])\n ->firstOrFail();\n\n if (! $organiserConnection->hasTelephonyProviderId()) {\n throw new LogicException(\n 'Unable to terminate call as we do not yet know the organiser telephony provider id',\n );\n }\n\n $twilioClient = $this->twilioClientBuilder->build($activity->getTeam());\n\n $twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());\n\n $session->hasTelephonyProviderId()\n && $twilioClient->terminateConference($session->getTelephonyProviderId());\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'session' => $session->id_string,\n ]);\n }\n\n public function answer(VO\\ConferenceManager\\ActivityAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'data' => $data,\n ]);\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneInboundAnswer) {\n return $this->answerInboundCall($data);\n }\n\n if ($data instanceof VO\\ConferenceManager\\SoftPhoneOutboundAnswer) {\n return $this->answerOutboundCall($data);\n }\n\n throw new LogicException('Unable to answer - not supported activity answer instance');\n }\n\n private function answerInboundCall(VO\\ConferenceManager\\SoftPhoneInboundAnswer $data): TwiML\\VoiceResponse\n {\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n ]);\n\n $dateTime = now();\n\n $activity = $this->create(\n new VO\\ConferenceManager\\SoftPhoneInboundCreate(\n $data->getCallerId(),\n $data->getCallSid(),\n $data->getUser()\n )\n );\n\n /** @var Models\\Participant $activityGuest */\n $activityGuest = $activity->from;\n\n $activityGuest\n ->setEnterTime($dateTime)\n ->save();\n\n /** @var Connection $connection */\n $connection = $activityGuest->connections()\n ->where('type', Connection::TYPE_PSTN)\n ->firstOrFail();\n\n $connection\n ->setTelephonyProviderId($data->getCallSid())\n ->setOpenedAt($dateTime)\n ->save();\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n return $this->joinTwilioConference($connection, [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]);\n }\n\n private function answerOutboundCall(VO\\ConferenceManager\\SoftPhoneOutboundAnswer $data): TwiML\\VoiceResponse\n {\n $connection = $data->getConnection();\n $participant = $connection->getParticipant();\n $activity = $data->getActivity();\n $organiser = $activity->organizer;\n $dateTime = now();\n\n $this->logger->debug(__METHOD__, [\n 'callSid' => $data->getCallSid(),\n 'connection' => $connection->id_string,\n 'participant' => $participant->id_string,\n 'activity' => $activity->id_string,\n ]);\n\n if (! $participant->hasEnterTime()) {\n $participant->update([\n 'enter_time' => $dateTime,\n ]);\n }\n\n $connection->update([\n 'is_muted' => $participant->isCoach(),\n 'opened_at' => $dateTime,\n ]);\n\n event(new Events\\Activities\\Connections\\Opened($connection));\n\n if ($participant->isCoach()) {\n /** @var Connection $roomOwnerConnection */\n $roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'coach' => $roomOwnerConnection->getTelephonyProviderId(),\n ]\n );\n }\n\n return $this->joinTwilioConference(\n $connection,\n [\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n 'muted' => false,\n ]\n );\n }\n\n private function determineUserCallerId(Models\\User $user): string\n {\n if ($user->hasCallerId()) {\n return $user->getCallerId();\n }\n\n // TODO: We should require a caller id to be verified before using the dialer\n return $user->getSoftPhoneNumber() ?? $user->phone;\n }\n\n private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool\n {\n $startsWith = '[HTTP 404] Unable to create record: The requested resource';\n $endsWith = 'was not found';\n\n return Str::startsWith($exceptionMessage, $startsWith)\n && Str::endsWith($exceptionMessage, $endsWith);\n }\n\n /**\n * @throws RestException\n * @throws ConfigurationException\n * @throws TwilioException\n */\n public function dialOtherParty(Connection $callerConnection): self\n {\n $session = $callerConnection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n $twilio = $this->twilioClientBuilder->build($activity->getTeam());\n\n $this->logger->debug(__METHOD__, [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {\n throw new UnexpectedValueException('Unexpected activity type');\n }\n\n $callee = $activity->to;\n $calleeId = $callee->getPhoneNumber();\n\n /** @var Connection $calleeConnection */\n $calleeConnection = $callee->connections()\n ->whereIn('type', [\n Connection::TYPE_PSTN,\n Connection::TYPE_WEBRTC,\n ])\n ->firstOrFail();\n\n $caller = $activity->from;\n\n // we need to take the number from the connection\n // as the one we store on participant level might be the one we call to the rep via PSTN mode\n $callerId = $callerConnection->getPhoneNumber();\n\n if (! $caller->is($callerConnection->getParticipant())) {\n throw new LogicException(sprintf(\n 'Unexpected caller participant. Expected: \"%s\", Actual: \"%s\"',\n $caller->id_string,\n $callerConnection->getParticipant()->id_string\n ));\n }\n\n $twilioConferenceSettings = [\n 'muted' => false,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => true,\n ];\n\n $hasInboundRecording = $activity->isTypeSoftphoneInbound()\n && $roomOwner->checkSoftphoneInboundRecordPreference();\n\n $hasOutboundRecording = $activity->isTypeSoftPhone()\n && $roomOwner->checkSoftphoneOutboundRecordPreference();\n\n $isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;\n\n $isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()\n || $activity->hasRecordingReasonComplianceRestricted();\n\n if ($isRecordingEnabled && ! $isRecordingRestricted) {\n $twilioConferenceSettings = array_merge($twilioConferenceSettings, [\n 'record' => true,\n 'recordingChannels' => 'dual',\n 'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),\n 'recordingStatusCallbackMethod' => 'POST',\n 'recordingStatusCallbackEvent' => 'in-progress completed absent',\n ]);\n\n if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {\n $twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()\n ? 'outbound'\n : 'inbound';\n }\n }\n\n try {\n if ($activity->hasRecordingReasonCompliancePrompted()) {\n $this->logger->info('[Softphone] Request compliance prompt via outbound call', [\n 'activity_uuid' => $activity->getUuid(),\n 'session' => $session->id_string,\n 'conference_sid' => $session->getTelephonyProviderId(),\n ]);\n\n $callInstance = $twilio->createCall(\n $callerId,\n $calleeId,\n $this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [\n 'session' => $session->id_string,\n 'connection' => $calleeConnection->id_string,\n ]),\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $callInstance->sid,\n 'parent_connection_id' => $callerConnection->getId(),\n ]);\n } else {\n $twilioParticipantConnection = $twilio->createParticipantInstance(\n $session->getTelephonyProviderId(),\n $callerId,\n $calleeId,\n $this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)\n );\n\n $calleeConnection->update([\n 'telephony_provider_id' => $twilioParticipantConnection->callSid,\n ]);\n }\n } catch (RestException $restException) {\n if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {\n throw $restException;\n }\n\n if (! $activity->isInFiniteState()) {\n $activity\n ->setStatus(Models\\Activity::STATUS_CANCELLED)\n ->save();\n\n $message = __METHOD__ . ': activity.status -> cancelled';\n } else {\n $message = sprintf(\n __METHOD__ . ': not setting to cancelled, activity.status is %s',\n $activity->getStatus()\n );\n }\n\n $this->logger->info($message, [\n 'activity' => $activity->id_string,\n 'exception' => $restException,\n ]);\n }\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n 'connection' => $callerConnection->id_string,\n ]);\n\n return $this;\n }\n\n private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array\n {\n $session = $connection->getSession();\n\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => true,\n 'endConferenceOnExit' => false,\n 'record' => false,\n 'waitUrl' => '',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]),\n 'statusCallbackEvent' => [\n 'initiated',\n 'ringing',\n 'answered',\n 'completed',\n ],\n 'statusCallbackMethod' => 'POST',\n 'conferenceStatusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n ]),\n 'conferenceStatusCallbackMethod' => 'POST',\n 'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function applyDefaultTwilioConferenceSettings(Models\\Session $session, array $override = []): array\n {\n $default = [\n 'beep' => false,\n 'muted' => true,\n 'startConferenceOnEnter' => false,\n 'endConferenceOnExit' => false,\n 'waitUrl' => '',\n 'statusCallbackEvent' => implode(' ', [\n 'start',\n 'end',\n 'join',\n 'leave',\n 'mute',\n 'hold',\n// 'speaker',\n ]),\n 'statusCallbackMethod' => 'POST',\n 'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [\n 'session' => $session->getIdString(),\n ]),\n ];\n\n if ($session->hasRegion()) {\n $default['region'] = $session->getRegion()->getName();\n }\n\n return array_merge($default, $override);\n }\n\n private function joinTwilioConference(Connection $connection, array $options = []): TwiML\\VoiceResponse\n {\n $session = $connection->getSession();\n $activity = $session->getActivity();\n $roomOwner = $activity->getUser();\n\n $this->logger->debug(__METHOD__, [\n 'connection' => $connection->id_string,\n 'session' => $session->id_string,\n 'activity' => $activity->id_string,\n 'user' => $roomOwner->id_string,\n ]);\n\n $voiceResponse = new TwiML\\VoiceResponse();\n\n $voiceResponse->dial()\n ->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [\n 'connection' => $connection->id_string,\n ]))\n ->conference(\n $session->getIdString(),\n $this->applyDefaultTwilioConferenceSettings($session, $options)\n );\n\n $this->logger->debug(__METHOD__ . ': done', [\n 'session' => $session->id_string,\n 'result' => $voiceResponse,\n ]);\n\n return $voiceResponse;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1434938327193753565
|
5862518280840782360
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12092 on JY-20613-allow- Project: faVsco.js, menu
#12092 on JY-20613-allow-owner-role-on-team-setup, menu
Start Listening for PHP Debug Connections
UserInvitationDTOTest
Run 'UserInvitationDTOTest'
Debug 'UserInvitationDTOTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Tests\Feature\DTO;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Jiminny\DTO\Invitation\UserInvitationDTO;
use Jiminny\Http\Requests\Settings\Teams\CreateTeamRequest;
use Jiminny\Models\Invitation;
use Jiminny\Models\Role;
use Jiminny\Models\Team;
use Jiminny\Models\User;
use Tests\TestCase;
final class UserInvitationDTOTest extends TestCase
{
use DatabaseTransactions;
public function testCreateFromInvitation(): void
{
/** @var Invitation $invitation */
$invitation = Invitation::factory()->create([
'email' => '[EMAIL]',
'group_id' => 1,
'team_id' => 1,
'role_id' => null,
'crm_required' => 1,
'is_owner' => 0,
]);
/** @var User $user */
$user = User::factory()->create();
/** @var Role $userRole */
$userRole = Role::whereName(User::ROLE_RECORDER)->first();
$invitation->roles()->sync($userRole);
$dto = UserInvitationDTO::fromInvitation($invitation, $user);
self::assertSame('[EMAIL]', $dto->email);
self::assertSame(1, $dto->groupId);
self::assertSame(1, $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertSame([$userRole->getId()], $dto->roleIds);
self::assertSame($user, $dto->currentUser);
}
public function testForOwner(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderRole */
$recorderRole = Role::whereName(User::ROLE_RECORDER)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithRecorderAndVoiceRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'recorder_and_voice',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $recorderAndVoiceRole */
$recorderAndVoiceRole = Role::whereName(User::ROLE_RECORDER_AND_VOICE)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $recorderAndVoiceRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
public function testForOwnerWithAnalystRole(): void
{
/** @var Team $team */
$team = Team::factory()->create();
$request = new CreateTeamRequest([
'owner_email' => '[EMAIL]',
'owner_role' => 'analyst',
]);
/** @var User $currentUser */
$currentUser = User::factory()->create();
$request->setUserResolver(static fn () => $currentUser);
$dto = UserInvitationDTO::forOwner($request, $team);
/** @var Role $analystRole */
$analystRole = Role::whereName(User::ROLE_ANALYST)->first();
/** @var Role $adminRole */
$adminRole = Role::whereName(User::ROLE_ADMIN)->first();
self::assertSame('[EMAIL]', $dto->email);
self::assertNull($dto->groupId);
self::assertSame($team->getId(), $dto->teamId);
self::assertTrue($dto->crmRequired);
self::assertEqualsCanonicalizing([$adminRole->getId(), $analystRole->getId()], $dto->roleIds);
self::assertSame($currentUser, $dto->currentUser);
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Component\Twilio\Conference\ConferenceManager;
use Illuminate\Database\Eloquent;
use Illuminate\Support\Str;
use Jiminny\Component\ElasticSearch\Model\Observer;
use Jiminny\Component\Twilio\Conference\ConferenceManager;
use Jiminny\Component\Twilio\Exception\OutOfBoundsException;
use Jiminny\Component\Twilio\Exception\UnexpectedValueException;
use Jiminny\Component\Twilio\Resolver;
use Jiminny\Component\Twilio\VO;
use Jiminny\Component\UrlGenerator\Webhook;
use Jiminny\Events;
use Jiminny\Exceptions\LogicException;
use Jiminny\Models;
use Jiminny\Models\Participant\Connection;
use Jiminny\Services\ActivityService;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Jiminny\Services\Crm\ProviderRegistry;
use Jiminny\Services\Telephony\TwilioClientBuilder;
use Psr\Log\LoggerInterface;
use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\RestException;
use Twilio\Exceptions\TwilioException;
use Twilio\TwiML;
final readonly class SoftPhoneManager implements ConferenceManager
{
public const int COMPLIANCE_PROMPT_TIMEOUT_SECONDS = 20;
public const string COMPLIANCE_PROMPT_ACCEPT_DIGIT = '1';
public function __construct(
private ProviderRegistry $crmProviderRegistry,
private Webhook $urlGenerator,
private ActivityService $activityService,
private LoggerInterface $logger,
private TwilioClientBuilder $twilioClientBuilder,
) {
}
public function create(VO\ConferenceManager\ActivityCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'data' => $data,
'instanceof' => get_class($data),
]);
$activity = Observer::skipEvents(function () use ($data) {
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundCreate) {
$activity = $this->createInboundActivity($data);
} elseif ($data instanceof VO\ConferenceManager\SoftPhoneOutboundCreate) {
$activity = $this->createOutboundActivity($data);
} else {
throw new LogicException(sprintf(
'Unknown softphone implementation: %s',
get_class($data)
));
}
return $activity;
});
$this->logger->debug(__METHOD__ . ': done', [
'instanceof' => get_class($data),
'activity' => $activity->id_string,
]);
return $activity;
}
private function createOutboundActivity(VO\ConferenceManager\SoftPhoneOutboundCreate $data): Models\Activity
{
$startTime = microtime(true);
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
$now = now();
$user = $data->getUser();
$team = $user->getTeam();
$device = $data->getDevice();
$phoneNumber = $data->getPhoneNumber();
$route = $data->getPhoneRoute();
if (! $this->activityService->teamCanDial($team, $phoneNumber)) {
$region = getRegionByNumber($phoneNumber, $user->getCountryCode());
throw new LogicException(sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? 'this number'
));
}
$lead = null;
$account = null;
$contact = null;
$stage = null;
$opportunity = null;
// Setup services.
$crmService = $this->crmProviderRegistry->get($team->crm->provider);
if (! $user->isCrmRequired()) {
$crmService->setUser($user->team->owner);
} else {
$crmService->setUser($user);
}
$prospectId = $data->getProspectId();
try {
if ($prospectId !== null) {
$crmStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM parseRecords', [
'prospect_id' => $prospectId,
'object_type' => $data->getObjectType(),
]);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->parseRecords(
$prospectId,
$data->getObjectType()
);
$this->logger->info('[SoftPhoneManager] CRM parseRecords completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'parse_duration_ms' => round((microtime(true) - $crmStart) * 1000, 2),
'found_lead' => $lead !== null,
'found_account' => $account !== null,
'found_opportunity' => $opportunity !== null,
'found_contact' => $contact !== null,
]);
// Try to attach a contact if we have an opportunity.
if ($opportunity !== null && $contact === null) {
$matchStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting CRM matchByPhone');
[, , , $contact, ] = $crmService->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
$this->logger->info('[SoftPhoneManager] CRM matchByPhone completed', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'match_duration_ms' => round((microtime(true) - $matchStart) * 1000, 2),
'found_contact' => $contact !== null,
]);
}
} else {
$decorator = resolve(CachedCrmServiceDecorator::class);
$decorator->setCrmService($crmService);
[$lead, $account, $opportunity, $contact, $stage] = $decorator->matchByPhone(
$data->getPhoneNumber(),
$data->getRawPhoneNumber(),
$user->getId()
);
}
} catch (\Throwable $throwable) {
$this->logger->error('[' . $crmService->getDisplayName() . '] Error looking up customer', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an outbound call from happening.
}
if ($data->hasProspectStage()) {
/** @var Models\Stage $stage */
$stage = $team->crm->stages()->uuid($data->getProspectStage());
}
$dbStart = microtime(true);
$this->logger->info('[SoftPhoneManager] Starting activity creation in DB');
$activity = $user->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE,
'provider' => Models\Activity::PROVIDER_TWILIO,
'device_id' => $device->id ?? null,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'value' => $opportunity ? $opportunity->value : null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_PENDING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $phoneNumber),
'scheduled_start_time' => $now,
]);
$this->logger->info('[SoftPhoneManager] Activity created in DB', [
'elapsed_ms' => round((microtime(true) - $startTime) * 1000, 2),
'db_duration_ms' => round((microtime(true) - $dbStart) * 1000, 2),
'activity_id' => $activity->id ?? null,
]);
if (! $activity instanceof Models\Activity) {
throw new LogicException('Activity model expected');
}
$regionId = null;
if ($user->hasRegionId()) {
$regionId = $user->getRegionId();
} elseif ($data->hasRegion()) {
$regionId = $data->getRegion()->getId();
}
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $regionId,
'is_muted' => false,
]);
$phoneRouteResolver = new Resolver\PhoneRouteResolver($user, $route);
$phoneNumberOrigin = $phoneRouteResolver->getPhoneNumberOrigin();
$connectionType = $phoneRouteResolver->getConnectionType();
$callerId = $this->determineUserCallerId($user);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'user_id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'phone_number' => $phoneRouteResolver->mustUsePSTN()
? $phoneNumberOrigin
: $callerId,
]);
$caller->connections()->create([
'session_id' => $session->getId(),
'direction' => $phoneRouteResolver->mustUsePSTN() ? 'outbound-api' : 'outbound',
'phone' => $callerId,
'type' => $connectionType,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $phoneNumber,
'name' => $activity->prospect_name,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'phone' => $phoneNumber,
'direction' => 'outbound-api',
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$activity->refresh();
$this->logger->info('[SoftPhoneManager] createOutboundActivity completed', [
'total_duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'activity_id' => $activity->id_string,
]);
$this->logger->debug(__METHOD__ . ': done', [
'data' => $data,
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
return $activity;
}
private function createInboundActivity(VO\ConferenceManager\SoftPhoneInboundCreate $data): Models\Activity
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$roomOwner = $data->getUser();
$team = $roomOwner->getTeam();
$phoneNumber = $roomOwner->getSoftPhoneInboundDestinationNumber();
$callerId = $data->getCallerId();
$callerCallSid = $data->getCallSid();
$now = now();
if (! $this->activityService->teamCanDial($roomOwner->getTeam(), $roomOwner->softphone_inbound_destination)) {
$region = getRegionByNumber($roomOwner->softphone_inbound_destination, $roomOwner->getCountryCode());
throw new LogicException(
sprintf(
'Sorry, dialing %s is not available with your subscription.'
. ' Please contact your manager to enable additional destinations.',
$region ?? ' this number',
),
);
}
$lead = $account = $opportunity = $contact = $stage = null;
try {
$crmService = $this->crmProviderRegistry->get($roomOwner->getTeam()->crm->provider);
$crmService->setUser($roomOwner);
[$lead, $account, $opportunity, $contact, $stage] = $crmService->matchByPhone(
$callerId,
null,
$roomOwner->getId()
);
} catch (\Throwable $throwable) {
$this->logger->error(__METHOD__ . ': crm exception', [
'exception' => $throwable,
]);
// maybe their crm is disconnected, but this shouldn't prevent an inbound call from happening
}
/** @var Models\Activity $activity */
$activity = $roomOwner->activities()->create([
'type' => Models\Activity::TYPE_SOFTPHONE_INBOUND,
'provider' => Models\Activity::PROVIDER_TWILIO,
'crm_configuration_id' => $team->crm_id,
'lead_id' => $lead->id ?? null,
'account_id' => $account->id ?? null,
'contact_id' => $contact->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => Models\Activity::STATUS_RINGING,
'recording_state' => Models\Activity::RECORDING_OFF,
'recording_reason_code' => $this->getVoiceRecordingReasonCode($team, $callerId),
'scheduled_start_time' => $now,
]);
/** @var Models\Session $session */
$session = $activity->sessions()->create([
'type' => Models\Session::TYPE_DIALER,
'region_id' => $roomOwner->getRegionId(),
'is_muted' => false,
]);
/** @var Models\Participant $caller */
$caller = $activity->participants()->create([
'lead_id' => $activity->lead_id ?? null,
'contact_id' => $activity->contact_id ?? null,
'phone_number' => $callerId,
'name' => $activity->prospect_name,
'enter_time' => $now,
]);
$caller->connections()->create([
'telephony_provider_id' => $callerCallSid,
'session_id' => $session->getId(),
'phone' => $callerId,
'direction' => 'inbound',
'type' => Connection::TYPE_PSTN,
'opened_at' => $now,
]);
/** @var Models\Participant $callee */
$callee = $activity->participants()->create([
'user_id' => $roomOwner->id,
'email' => $roomOwner->email,
'name' => $roomOwner->name,
'phone_number' => $phoneNumber,
]);
$callee->connections()->create([
'session_id' => $session->getId(),
'direction' => 'outbound-api',
'phone' => $phoneNumber,
'type' => Connection::TYPE_PSTN,
]);
$activity->update([
'from_participant_id' => $caller->id,
'to_participant_id' => $callee->id,
]);
$this->logger->debug(__METHOD__ . ': done', [
'callSid' => $data->getCallSid(),
'activity' => $activity->id_string,
'session' => $session->id_string,
'caller' => $caller->id_string,
'callee' => $callee->id_string,
]);
$activity->refresh();
return $activity;
}
private function getVoiceRecordingReasonCode(Models\Team $team, string $phoneNumber): int
{
$this->logger->debug(__METHOD__, [
'team' => $team->id_string,
'phone' => $phoneNumber,
]);
if (
! $team->hasComplianceModeRecordingPrompt()
&& ! $team->hasComplianceModeRecordingRestrictToOneSide()
&& ! $team->hasComplianceModeRecordingRestrictRecording()
) {
return 0;
}
/** @var Models\VoiceConsentPrefix|null $voiceConsentPolicy */
$voiceConsentPolicy = Models\VoiceConsentPrefix::query()
->whereRaw("? LIKE CONCAT(destination_prefix, '%')", [$phoneNumber])
->where(static function (Eloquent\Builder $builder) use ($team): void {
$builder
->whereNull('team_id')
->orWhere('team_id', $team->getId());
})
->orderByDesc('team_id')
->first();
if (! $voiceConsentPolicy instanceof Models\VoiceConsentPrefix || $voiceConsentPolicy->isAllowed()) {
return 0;
}
if ($team->hasComplianceModeRecordingPrompt()) {
// This number requires consent from the other party before recording the call.
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_PROMPT;
}
if ($team->hasComplianceModeRecordingRestrictToOneSide()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT_ONE_SIDE;
}
if ($team->hasComplianceModeRecordingRestrictRecording()) {
return Models\Activity::FLAG_RECORDING_REASON_COMPLIANCE_RESTRICT;
}
return 0;
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOrganiser(Models\Activity $activity): void
{
$this->logger->debug(__METHOD__, [
'activity' => $activity->id_string,
]);
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$canBeDialed =
$activity->isTypeSoftPhone()
&& ! $activity->hasStarted()
&& $activity->isPending()
&& $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->exists();
if (! $canBeDialed) {
throw new OutOfBoundsException('Cannot make an outbound call to this activity.');
}
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->where('type', '=', Connection::TYPE_PSTN)
->whereNull('opened_at')
->firstOrFail();
/** @var Models\Session $session */
$session = $organiserConnection->getSession();
$twilioConferenceSettings = $this->applyDefaultTwilioParticipantSettings($organiserConnection, [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
]);
// What number will the organizer see as caller id
$callerId = $roomOwner->getSoftPhoneNumber();
if ($callerId === null) {
$callee = $activity->participants()->whereNull('user_id')->first();
// This is a bridge call. We make it look like the callee is calling the organizer
$callerId = $callee->phone_number;
}
// What number will the prospect see as caller id
$calleeId = $organiser->getPhoneNumber();
$this->logger->debug(__METHOD__ . ': twilio call', [
'calleeId' => $calleeId,
'callerId' => $callerId,
]);
$twilioParticipant = $twilio->createParticipantInstance(
$session->id_string,
$callerId,
$calleeId,
$twilioConferenceSettings
);
$organiserConnection
->setTelephonyProviderId($twilioParticipant->callSid)
->save();
$session
->setTelephonyProviderId($twilioParticipant->conferenceSid)
->save();
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'session' => $session->id_string,
]);
}
public function dialCoach(Models\Activity $activity, Models\User $user): void
{
throw new \LogicException('Not implemented yet');
}
/**
* @throws ConfigurationException
* @throws TwilioException
*/
public function terminate(VO\ConferenceManager\ActivityTerminate $data): void
{
if (! $data instanceof VO\ConferenceManager\SoftphoneTerminate) {
throw new UnexpectedValueException(sprintf(
'Expecting an instance of "%s", but "%s" given.',
VO\ConferenceManager\SoftphoneTerminate::class,
\get_class($data)
));
}
$this->logger->debug(__METHOD__, [
'activity' => $data->getActivity()->id_string,
'user' => $data->getUser()->id_string,
]);
$activity = $data->getActivity();
$roomOwner = $activity->getUser();
$isCancelling = ! $activity->isInFiniteState() && ! $activity->isInProgress();
if ($isCancelling) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
}
/** @var Models\Session $session */
$session = $activity->sessions->first();
/** @var Models\Participant $organiser */
$organiser = $activity->organizer;
/** @var Connection $organiserConnection */
$organiserConnection = $organiser->connections()
->whereIn('type', [
Connection::TYPE_WEBRTC,
Connection::TYPE_PSTN,
])
->firstOrFail();
if (! $organiserConnection->hasTelephonyProviderId()) {
throw new LogicException(
'Unable to terminate call as we do not yet know the organiser telephony provider id',
);
}
$twilioClient = $this->twilioClientBuilder->build($activity->getTeam());
$twilioClient->terminateVoiceCall($organiserConnection->getTelephonyProviderId());
$session->hasTelephonyProviderId()
&& $twilioClient->terminateConference($session->getTelephonyProviderId());
$this->logger->debug(__METHOD__ . ': done', [
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'session' => $session->id_string,
]);
}
public function answer(VO\ConferenceManager\ActivityAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'data' => $data,
]);
if ($data instanceof VO\ConferenceManager\SoftPhoneInboundAnswer) {
return $this->answerInboundCall($data);
}
if ($data instanceof VO\ConferenceManager\SoftPhoneOutboundAnswer) {
return $this->answerOutboundCall($data);
}
throw new LogicException('Unable to answer - not supported activity answer instance');
}
private function answerInboundCall(VO\ConferenceManager\SoftPhoneInboundAnswer $data): TwiML\VoiceResponse
{
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
]);
$dateTime = now();
$activity = $this->create(
new VO\ConferenceManager\SoftPhoneInboundCreate(
$data->getCallerId(),
$data->getCallSid(),
$data->getUser()
)
);
/** @var Models\Participant $activityGuest */
$activityGuest = $activity->from;
$activityGuest
->setEnterTime($dateTime)
->save();
/** @var Connection $connection */
$connection = $activityGuest->connections()
->where('type', Connection::TYPE_PSTN)
->firstOrFail();
$connection
->setTelephonyProviderId($data->getCallSid())
->setOpenedAt($dateTime)
->save();
event(new Events\Activities\Connections\Opened($connection));
return $this->joinTwilioConference($connection, [
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]);
}
private function answerOutboundCall(VO\ConferenceManager\SoftPhoneOutboundAnswer $data): TwiML\VoiceResponse
{
$connection = $data->getConnection();
$participant = $connection->getParticipant();
$activity = $data->getActivity();
$organiser = $activity->organizer;
$dateTime = now();
$this->logger->debug(__METHOD__, [
'callSid' => $data->getCallSid(),
'connection' => $connection->id_string,
'participant' => $participant->id_string,
'activity' => $activity->id_string,
]);
if (! $participant->hasEnterTime()) {
$participant->update([
'enter_time' => $dateTime,
]);
}
$connection->update([
'is_muted' => $participant->isCoach(),
'opened_at' => $dateTime,
]);
event(new Events\Activities\Connections\Opened($connection));
if ($participant->isCoach()) {
/** @var Connection $roomOwnerConnection */
$roomOwnerConnection = $organiser->connections()->openCall()->firstOrFail();
return $this->joinTwilioConference(
$connection,
[
'coach' => $roomOwnerConnection->getTelephonyProviderId(),
]
);
}
return $this->joinTwilioConference(
$connection,
[
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
'muted' => false,
]
);
}
private function determineUserCallerId(Models\User $user): string
{
if ($user->hasCallerId()) {
return $user->getCallerId();
}
// TODO: We should require a caller id to be verified before using the dialer
return $user->getSoftPhoneNumber() ?? $user->phone;
}
private function isRestExceptionAboutTwilioConferenceNotFound(string $exceptionMessage): bool
{
$startsWith = '[HTTP 404] Unable to create record: The requested resource';
$endsWith = 'was not found';
return Str::startsWith($exceptionMessage, $startsWith)
&& Str::endsWith($exceptionMessage, $endsWith);
}
/**
* @throws RestException
* @throws ConfigurationException
* @throws TwilioException
*/
public function dialOtherParty(Connection $callerConnection): self
{
$session = $callerConnection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$twilio = $this->twilioClientBuilder->build($activity->getTeam());
$this->logger->debug(__METHOD__, [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
if (! $activity->isTypeSoftPhone() && ! $activity->isTypeSoftphoneInbound()) {
throw new UnexpectedValueException('Unexpected activity type');
}
$callee = $activity->to;
$calleeId = $callee->getPhoneNumber();
/** @var Connection $calleeConnection */
$calleeConnection = $callee->connections()
->whereIn('type', [
Connection::TYPE_PSTN,
Connection::TYPE_WEBRTC,
])
->firstOrFail();
$caller = $activity->from;
// we need to take the number from the connection
// as the one we store on participant level might be the one we call to the rep via PSTN mode
$callerId = $callerConnection->getPhoneNumber();
if (! $caller->is($callerConnection->getParticipant())) {
throw new LogicException(sprintf(
'Unexpected caller participant. Expected: "%s", Actual: "%s"',
$caller->id_string,
$callerConnection->getParticipant()->id_string
));
}
$twilioConferenceSettings = [
'muted' => false,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => true,
];
$hasInboundRecording = $activity->isTypeSoftphoneInbound()
&& $roomOwner->checkSoftphoneInboundRecordPreference();
$hasOutboundRecording = $activity->isTypeSoftPhone()
&& $roomOwner->checkSoftphoneOutboundRecordPreference();
$isRecordingEnabled = $hasInboundRecording || $hasOutboundRecording;
$isRecordingRestricted = $activity->hasRecordingReasonCompliancePrompted()
|| $activity->hasRecordingReasonComplianceRestricted();
if ($isRecordingEnabled && ! $isRecordingRestricted) {
$twilioConferenceSettings = array_merge($twilioConferenceSettings, [
'record' => true,
'recordingChannels' => 'dual',
'recordingStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.recording.event'),
'recordingStatusCallbackMethod' => 'POST',
'recordingStatusCallbackEvent' => 'in-progress completed absent',
]);
if ($activity->hasRecordingReasonComplianceRestrictedToOneSideRecording()) {
$twilioConferenceSettings['recordingTrack'] = $activity->isTypeSoftPhone()
? 'outbound'
: 'inbound';
}
}
try {
if ($activity->hasRecordingReasonCompliancePrompted()) {
$this->logger->info('[Softphone] Request compliance prompt via outbound call', [
'activity_uuid' => $activity->getUuid(),
'session' => $session->id_string,
'conference_sid' => $session->getTelephonyProviderId(),
]);
$callInstance = $twilio->createCall(
$callerId,
$calleeId,
$this->urlGenerator->route('jiminny.webhook.softphone.compliance.prompt', [
'session' => $session->id_string,
'connection' => $calleeConnection->id_string,
]),
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $callInstance->sid,
'parent_connection_id' => $callerConnection->getId(),
]);
} else {
$twilioParticipantConnection = $twilio->createParticipantInstance(
$session->getTelephonyProviderId(),
$callerId,
$calleeId,
$this->applyDefaultTwilioParticipantSettings($calleeConnection, $twilioConferenceSettings)
);
$calleeConnection->update([
'telephony_provider_id' => $twilioParticipantConnection->callSid,
]);
}
} catch (RestException $restException) {
if (! $this->isRestExceptionAboutTwilioConferenceNotFound($restException->getMessage())) {
throw $restException;
}
if (! $activity->isInFiniteState()) {
$activity
->setStatus(Models\Activity::STATUS_CANCELLED)
->save();
$message = __METHOD__ . ': activity.status -> cancelled';
} else {
$message = sprintf(
__METHOD__ . ': not setting to cancelled, activity.status is %s',
$activity->getStatus()
);
}
$this->logger->info($message, [
'activity' => $activity->id_string,
'exception' => $restException,
]);
}
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
'connection' => $callerConnection->id_string,
]);
return $this;
}
private function applyDefaultTwilioParticipantSettings(Connection $connection, array $override = []): array
{
$session = $connection->getSession();
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => true,
'endConferenceOnExit' => false,
'record' => false,
'waitUrl' => '',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]),
'statusCallbackEvent' => [
'initiated',
'ringing',
'answered',
'completed',
],
'statusCallbackMethod' => 'POST',
'conferenceStatusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
]),
'conferenceStatusCallbackMethod' => 'POST',
'conferenceStatusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function applyDefaultTwilioConferenceSettings(Models\Session $session, array $override = []): array
{
$default = [
'beep' => false,
'muted' => true,
'startConferenceOnEnter' => false,
'endConferenceOnExit' => false,
'waitUrl' => '',
'statusCallbackEvent' => implode(' ', [
'start',
'end',
'join',
'leave',
'mute',
'hold',
// 'speaker',
]),
'statusCallbackMethod' => 'POST',
'statusCallback' => $this->urlGenerator->route('jiminny.webhook.softphone.conference.event', [
'session' => $session->getIdString(),
]),
];
if ($session->hasRegion()) {
$default['region'] = $session->getRegion()->getName();
}
return array_merge($default, $override);
}
private function joinTwilioConference(Connection $connection, array $options = []): TwiML\VoiceResponse
{
$session = $connection->getSession();
$activity = $session->getActivity();
$roomOwner = $activity->getUser();
$this->logger->debug(__METHOD__, [
'connection' => $connection->id_string,
'session' => $session->id_string,
'activity' => $activity->id_string,
'user' => $roomOwner->id_string,
]);
$voiceResponse = new TwiML\VoiceResponse();
$voiceResponse->dial()
->setAction($this->urlGenerator->route('jiminny.webhook.softphone.participant.event', [
'connection' => $connection->id_string,
]))
->conference(
$session->getIdString(),
$this->applyDefaultTwilioConferenceSettings($session, $options)
);
$this->logger->debug(__METHOD__ . ': done', [
'session' => $session->id_string,
'result' => $voiceResponse,
]);
return $voiceResponse;
}
}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
60889
|
NULL
|
NULL
|
NULL
|