|
55300
|
1914
|
47
|
2026-05-18T14:00:44.608270+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112844608_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncActivity.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditView→HistoryBookmarksProfilesToolsW FirefoxFileEditView→HistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com100% <78 • Mon 18 May 17:00:44A05000Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik5:00 PM | [Platform] Refinement ®1:47:11...
|
NULL
|
-3131327756003411310
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEditView→HistoryBookmarksProfilesToolsW FirefoxFileEditView→HistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com100% <78 • Mon 18 May 17:00:44A05000Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik5:00 PM | [Platform] Refinement ®1:47:11...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55291
|
1915
|
25
|
2026-05-18T14:00:08.148969+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112808148_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncActivity.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormProjectFV faVsco.jsVIewg9 master k -INavic PhpStormProjectFV faVsco.jsVIewg9 master k -INavicarecodeLaravelRefactorTOOIS© ReindexForUserJob.phpketуAcuvilysyncJob.onosyncacuivity.ongleardownstream.onpD AiAutomation_A keDorsD AudioAutomatedReports107C) RequestgenerateAskJiminnyReportJob.c108C) RequestgenerateReportJob.pho109© SendReportExpiringSoonMailJob.php110c) SendReportJob.php111© SendReportMailJob.phpc) SendReoortNotGeneratedMail.loo.ond> M Calendarv 17Cmm> O Delete> M HubsootSalesforce(c) Autoloabelavedtocrm.nhnl© CheckAndRetryRemoteMatch.php© CreateFollowupActivity.php© CreateNotes.php© MatchActivitiesToNewOpportunity.php© MatchActivityCrmData.php® NoteObject.php© SaveActivity.php112113114115116117118>1246147148149150151© SaveTranscription.php© SetupLayout.php© SyncActivity.php© SyncFieldMetadata.php© SyncHubspotObjects.php© SyncLeads.php© SyncObjects.php@ SvncOpportunities.Job.ohv© SyncOpportunity.phpC) SvncProfileMetadata.oho©SyncTeamFieldsJob.phpC) SvncTeamMetadata.oho©UpdateOpportunitySpecifications.phpC) UndateStade.oholM DealRisksM Mailboy165188® ActivityController.php© CoreUserRequest.php© SoftPhoneManager.php© CoreUser.php© Activity/RingCentral/Service.phpSyncactivity.onp xccrm/close/service.ohoclass Syncactivity extends Job 1mpLements Shouldoueue* @throws ActivityProviderException* @throws SocialAccountTokenInvalidException* Othrows ContainerExceptionInterface* @throws NotFoundExceptionInterfaceê | Analyzing...N MiddlewardStreamingIM TeamTelephonymlisor© ChangeEmailJob.phpDeactivateUserJob.phpprivate function init(ActivityImportManager $activityImportManager,ActivityProviderRegistry $activityProviderRegistry,UserRepository $userRepository,SentryClient $sentry,LoggerIntertace >Logger,): void f.7private function run(): ActivityImportResult$this->logger->info('[SyncActivity] Start', $this->context);$this->activityImportManager->start($this->import);SimportedRecords = Sthis->service-importData(sthis->imoort->qetstartlateo.sthis->userRenosi.torv->findoneßvarid' = sthis-simoort->oetiser1d0.DrSthis->import->getActivityId()return (new ActivityImportResult())->setTotal($importedRecords)->addImported($importedRecords)private function complete(ActivityImportResult $result): voidf...,private function failImport(Throwable Sexception): voidf….,Il Pollback II Confiaure (todav 14-19)=custom.logA console [STAGING]stoheE laravel.log4 SF [jiminny@localhost]© CoachingFeedbackCoachUserin.phpxA HS_Jocal [jiminny@localhost]A console [PROD]A console (EU]declare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 đ >34 đ >45 đ >135136 € >146 @ >150151 G>1561usadeorivate const int No GrOUp 1d = 999-private UserRepository $userRepository;public function __construct(UserRepository $userRepository){.}public function shouldApplyQueries(): boolf...}public function getQueries(): FilterDefinitionQueryCollectionf...+public function toArray(): arrayf.,private function getOptions(): arrayf...,public function getValue(): arrayf..,private function getDefaultValue(): arrayf...,public function getValidationRules(?string $prefix = null): arrayf…..,public function getSortOrder(): intf...}public function shouldßeIncluded(Team $team): b00lf...}llcanos of l liminnulc100% 28• Mon 18 May 17:00:08CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..php on LineWARN Metadata found in doc-comment for methodw9 / 10 tasks done :• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META_ROLES and USER_PATCH_ATTR_ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintegration.8 Hes with chingon rapp/Component/SClM/ Constants.php +3app/Component/SCIM/ ScimProvisioning.php +85-15nse/D CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/lcer/M RoleAttr.nhn t17ites/Userl ẞ RoleAttrTest.ohn +224Ask anvthina (&4-L)* Reiect alliiAccent allWN Windsurf Teams152•14UTS.8f 4 spaces...
|
NULL
|
-5337550897012830256
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhpStormProjectFV faVsco.jsVIewg9 master k -INavic PhpStormProjectFV faVsco.jsVIewg9 master k -INavicarecodeLaravelRefactorTOOIS© ReindexForUserJob.phpketуAcuvilysyncJob.onosyncacuivity.ongleardownstream.onpD AiAutomation_A keDorsD AudioAutomatedReports107C) RequestgenerateAskJiminnyReportJob.c108C) RequestgenerateReportJob.pho109© SendReportExpiringSoonMailJob.php110c) SendReportJob.php111© SendReportMailJob.phpc) SendReoortNotGeneratedMail.loo.ond> M Calendarv 17Cmm> O Delete> M HubsootSalesforce(c) Autoloabelavedtocrm.nhnl© CheckAndRetryRemoteMatch.php© CreateFollowupActivity.php© CreateNotes.php© MatchActivitiesToNewOpportunity.php© MatchActivityCrmData.php® NoteObject.php© SaveActivity.php112113114115116117118>1246147148149150151© SaveTranscription.php© SetupLayout.php© SyncActivity.php© SyncFieldMetadata.php© SyncHubspotObjects.php© SyncLeads.php© SyncObjects.php@ SvncOpportunities.Job.ohv© SyncOpportunity.phpC) SvncProfileMetadata.oho©SyncTeamFieldsJob.phpC) SvncTeamMetadata.oho©UpdateOpportunitySpecifications.phpC) UndateStade.oholM DealRisksM Mailboy165188® ActivityController.php© CoreUserRequest.php© SoftPhoneManager.php© CoreUser.php© Activity/RingCentral/Service.phpSyncactivity.onp xccrm/close/service.ohoclass Syncactivity extends Job 1mpLements Shouldoueue* @throws ActivityProviderException* @throws SocialAccountTokenInvalidException* Othrows ContainerExceptionInterface* @throws NotFoundExceptionInterfaceê | Analyzing...N MiddlewardStreamingIM TeamTelephonymlisor© ChangeEmailJob.phpDeactivateUserJob.phpprivate function init(ActivityImportManager $activityImportManager,ActivityProviderRegistry $activityProviderRegistry,UserRepository $userRepository,SentryClient $sentry,LoggerIntertace >Logger,): void f.7private function run(): ActivityImportResult$this->logger->info('[SyncActivity] Start', $this->context);$this->activityImportManager->start($this->import);SimportedRecords = Sthis->service-importData(sthis->imoort->qetstartlateo.sthis->userRenosi.torv->findoneßvarid' = sthis-simoort->oetiser1d0.DrSthis->import->getActivityId()return (new ActivityImportResult())->setTotal($importedRecords)->addImported($importedRecords)private function complete(ActivityImportResult $result): voidf...,private function failImport(Throwable Sexception): voidf….,Il Pollback II Confiaure (todav 14-19)=custom.logA console [STAGING]stoheE laravel.log4 SF [jiminny@localhost]© CoachingFeedbackCoachUserin.phpxA HS_Jocal [jiminny@localhost]A console [PROD]A console (EU]declare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 đ >34 đ >45 đ >135136 € >146 @ >150151 G>1561usadeorivate const int No GrOUp 1d = 999-private UserRepository $userRepository;public function __construct(UserRepository $userRepository){.}public function shouldApplyQueries(): boolf...}public function getQueries(): FilterDefinitionQueryCollectionf...+public function toArray(): arrayf.,private function getOptions(): arrayf...,public function getValue(): arrayf..,private function getDefaultValue(): arrayf...,public function getValidationRules(?string $prefix = null): arrayf…..,public function getSortOrder(): intf...}public function shouldßeIncluded(Team $team): b00lf...}llcanos of l liminnulc100% 28• Mon 18 May 17:00:08CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..php on LineWARN Metadata found in doc-comment for methodw9 / 10 tasks done :• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META_ROLES and USER_PATCH_ATTR_ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintegration.8 Hes with chingon rapp/Component/SClM/ Constants.php +3app/Component/SCIM/ ScimProvisioning.php +85-15nse/D CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/lcer/M RoleAttr.nhn t17ites/Userl ẞ RoleAttrTest.ohn +224Ask anvthina (&4-L)* Reiect alliiAccent allWN Windsurf Teams152•14UTS.8f 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55290
|
1915
|
24
|
2026-05-18T14:00:04.837946+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112804837_m2.jpg...
|
PhpStorm
|
faVsco.js – SyncActivity.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master k >Proiect v© SoftPhoneManager.phpYC) ParentNode.ohrC) CoreUserRequest.pnp© CoreUser.php© RolesAndCrmOptionMismatchRule.phpc)SmsMessage.one© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnect0 Apollo> @J AvayaC BaseService>_ bloobirasu Closea CloudcallD CloudTalkConnectAndSellD DemoDeskDialpadM FightBvFiahtFiveNineM GmaiD Gong• GoogleMeetGoToMeetingM GrooveM LuhSootImportJiminnyD JustCallMigratorh Natterhay0 Office0 Orumm Outreach• C RingCentral© Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoC7 Salesforce• Sales oftTalkdeskM TeamsTelusM TwilidM TwilioflexDirectM TwilioVidenM Uinloade"N VonaadM Xanti• M7oom© Activity/RingCentral/Service.phpsyncacuivity.onp568898class Syncactivity extends Job 1mpLements Shouldoueue165*dchrows Actzvitu?rovzderzxceotzon*dchrows SoczalAccountloken.nva.dzxcentzon* dthrows containerExceotioninterface• dthrows NotFoundExcentioninterfaceActivityImportManager SactivityImportManager,ActivityProviderRegistry $activityProviderRegistry.UserRepository SuserRepository,SentryClient $sentry.LoggerInterface Slogger,): void {...}private function run: ActivityImportResultSthis->logger->info('[SyncActivity] Start'. Sthis->context):c+hic-sactivitvTmnortManager->start(Sth1s->1mporc)SimportedRecords•vice-›importData(sth1s->import->qetstartbateohnthns->imoort->qetznouateoiothas->userkenository->rindunesvu'1d' => sth1s->1moort->cetuserdooSthis->import->qetActivitvIdoreturn inew ActivitvimoortResultool->addImnortedcsimnortedRecords)private function complete(ActivityImportResult $result): void(...}private function failImport(Throwable Sexception): void{...}100% Lz• Mon 18 May 17:00:04AskJiminnyReportActivityServiceTest v= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]CascadeA console [STAGING]© CoachingFeedbackCoachUserln.php XstoheA1A Ydeclarelscrict_cypes=1)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >135136 Gt ›146 @t>150151 C1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray@: array{...;private function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}lleanos ofl liminnvlc...
|
NULL
|
-6134989073001016334
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master k >Proiect v© SoftPhoneManager.phpYC) ParentNode.ohrC) CoreUserRequest.pnp© CoreUser.php© RolesAndCrmOptionMismatchRule.phpc)SmsMessage.one© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnect0 Apollo> @J AvayaC BaseService>_ bloobirasu Closea CloudcallD CloudTalkConnectAndSellD DemoDeskDialpadM FightBvFiahtFiveNineM GmaiD Gong• GoogleMeetGoToMeetingM GrooveM LuhSootImportJiminnyD JustCallMigratorh Natterhay0 Office0 Orumm Outreach• C RingCentral© Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoC7 Salesforce• Sales oftTalkdeskM TeamsTelusM TwilidM TwilioflexDirectM TwilioVidenM Uinloade"N VonaadM Xanti• M7oom© Activity/RingCentral/Service.phpsyncacuivity.onp568898class Syncactivity extends Job 1mpLements Shouldoueue165*dchrows Actzvitu?rovzderzxceotzon*dchrows SoczalAccountloken.nva.dzxcentzon* dthrows containerExceotioninterface• dthrows NotFoundExcentioninterfaceActivityImportManager SactivityImportManager,ActivityProviderRegistry $activityProviderRegistry.UserRepository SuserRepository,SentryClient $sentry.LoggerInterface Slogger,): void {...}private function run: ActivityImportResultSthis->logger->info('[SyncActivity] Start'. Sthis->context):c+hic-sactivitvTmnortManager->start(Sth1s->1mporc)SimportedRecords•vice-›importData(sth1s->import->qetstartbateohnthns->imoort->qetznouateoiothas->userkenository->rindunesvu'1d' => sth1s->1moort->cetuserdooSthis->import->qetActivitvIdoreturn inew ActivitvimoortResultool->addImnortedcsimnortedRecords)private function complete(ActivityImportResult $result): void(...}private function failImport(Throwable Sexception): void{...}100% Lz• Mon 18 May 17:00:04AskJiminnyReportActivityServiceTest v= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]& console [EU]CascadeA console [STAGING]© CoachingFeedbackCoachUserln.php XstoheA1A Ydeclarelscrict_cypes=1)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >135136 Gt ›146 @t>150151 C1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray@: array{...;private function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}lleanos ofl liminnvlc...
|
55288
|
NULL
|
NULL
|
NULL
|
|
55289
|
1914
|
41
|
2026-05-18T14:00:02.364184+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112802364_m1.jpg...
|
PhpStorm
|
faVsco.js – SyncActivity.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFile• 0EditViewHistory→BookmarksProfilesToo FirefoxFile• 0EditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com100% C8 • Mon 18 May 17:00:025Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik5:00 PM | [Platform] Refinement ®1:46:29...
|
NULL
|
-8730374534583533196
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFile• 0EditViewHistory→BookmarksProfilesToo FirefoxFile• 0EditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com100% C8 • Mon 18 May 17:00:025Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik5:00 PM | [Platform] Refinement ®1:46:29...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55287
|
1914
|
40
|
2026-05-18T14:00:00.195282+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112800195_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistory→BookmarksProfilesToolsW FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 17:00:0040 5010•j•Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:46:27...
|
NULL
|
-2338268851592535955
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistory→BookmarksProfilesToolsW FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 17:00:0040 5010•j•Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:46:27...
|
55285
|
NULL
|
NULL
|
NULL
|
|
55286
|
1915
|
22
|
2026-05-18T13:59:57.138661+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112797138_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicarecodeLaravelFV faVsco.js°9 mas PhostormVIewINavicarecodeLaravelFV faVsco.js°9 master k >Proiect v© ParentNode.php(C) CoreUserRequest.onp© RolesAndCrmOptionMismatchRule.php(c)SmsMessage.pnp© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnectJ Apollo> @J AvayaC BaseService95 Gt› _ bloobirasuCloseCloudcallCloudtalkConnectAndSellD DemoDeskDialpadl sichisvziahFiveNineM GmaillWiminny Services Activitv RinaCentr• Methodm d importData service ...app/services/Activity RinaCen> Usages in Prolect Files 1 result: [Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:56C BaseService.pnp© SoftPhoneManager.php= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyc ScimProvisioning.ong© CoreUser.phpA console [STAGING]© CoachingFeedbackCoachUserln.php Xfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefini~public tunction -_construct(Userrepository suserkepository)l...© Activity/Close/service.pnp© Activity/RingCentral/Service.php x29 Oclass service extends Activityproviderservice impLements Importsbataintertace mA21 M421 AV340>* acnrows Jiminny.cxcepczons Hcconocroundexcept10n* @throwsJiminny\Exceptions\QuotaExceededException= 444So)* dcnrows Jiminnu excepcions serviceunavazladlecxcepezonublic function importbata@carbon Sfrom = nulu, ?carbon Sto = nulu. ?User suser = null, ?stringif (suser == nulusthis->loqger-warnina'User filter 1s not avallable with this provider')isthas->hassalesforceinteorated = sthis->team->crm->Freldso ->where oierm orovider id'= self::CRM CALL IDI'object_type' => 'task',1->existso:Е131 ,=155136 @›145public function shouldApplyQueries: boolk...,public function getQueries: FilterDefinitionQueryCollectiont...public function toArray@: arrayt...private function getOptions): array{...}public function getValue: arrayi...}private function getDefaultValue(): array{...}public function getValidationRules(?string Sprefix = null): array{...}CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role ManagemeWARN Metadata found iin doc-comment for method9/ 10 tacke done8 tiles/U ListenerkoiecannotHaveAaminurmanagerrermissionkuie.pnp +39apo/Comoonent/SClM/=Constants.ono +e/ u coreUser.phpapp/Component/SCIM/Mutators/Attributes/User/® RoleAttr.php +171ites/User/ RoleAttrTest.php +224ann/DTO/SCIMIAAD/Request/MCoreUserRequest.ohn t15Ask anvthina (*4D1+ <> Code SWE-1.6+0 ..inp on LineView all* Reject allAccent alllActivity:: TYPE_SOFTPHONE.Activity:: TYPE SOFTPHONE INBOUND.95@* @throws SocialAccountTokenInvalidexception* othrows Viminnu Exceotions HttoBadRequestExceptionathrows Jiminny Exceptions HttpForbiddenException• Othrows Waimannu ExcentionsHttoNotFoundExcentzonOthrows Jiminny Exceptions QuotaExceededExceptionAthrows Waiminnu ExcentionsServicellnavalahleExcentionpublic function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = nulU): ?intif (Sucen 1== null)d$this->logger->warning('User filter is not available with this provider');$this->hasSalesforceIntegrated = Sthis->team->crm->fields->where(['crm provider id' => self::CRM_CALL ID'object type' =>])->exists0:Sthis->cachedCrmServiceDecorator = resolve( name: CachedCrmServiceDecorator::class):wweAdAhodieamConthoic fdantaudeuta)hCall HierarchyW Windsurf Toams 05-21/10 charc) UTF.8 Al A enasoe...
|
NULL
|
1787670353670906332
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIewINavicarecodeLaravelFV faVsco.js°9 mas PhostormVIewINavicarecodeLaravelFV faVsco.js°9 master k >Proiect v© ParentNode.php(C) CoreUserRequest.onp© RolesAndCrmOptionMismatchRule.php(c)SmsMessage.pnp© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnectJ Apollo> @J AvayaC BaseService95 Gt› _ bloobirasuCloseCloudcallCloudtalkConnectAndSellD DemoDeskDialpadl sichisvziahFiveNineM GmaillWiminny Services Activitv RinaCentr• Methodm d importData service ...app/services/Activity RinaCen> Usages in Prolect Files 1 result: [Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:56C BaseService.pnp© SoftPhoneManager.php= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyc ScimProvisioning.ong© CoreUser.phpA console [STAGING]© CoachingFeedbackCoachUserln.php Xfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefini~public tunction -_construct(Userrepository suserkepository)l...© Activity/Close/service.pnp© Activity/RingCentral/Service.php x29 Oclass service extends Activityproviderservice impLements Importsbataintertace mA21 M421 AV340>* acnrows Jiminny.cxcepczons Hcconocroundexcept10n* @throwsJiminny\Exceptions\QuotaExceededException= 444So)* dcnrows Jiminnu excepcions serviceunavazladlecxcepezonublic function importbata@carbon Sfrom = nulu, ?carbon Sto = nulu. ?User suser = null, ?stringif (suser == nulusthis->loqger-warnina'User filter 1s not avallable with this provider')isthas->hassalesforceinteorated = sthis->team->crm->Freldso ->where oierm orovider id'= self::CRM CALL IDI'object_type' => 'task',1->existso:Е131 ,=155136 @›145public function shouldApplyQueries: boolk...,public function getQueries: FilterDefinitionQueryCollectiont...public function toArray@: arrayt...private function getOptions): array{...}public function getValue: arrayi...}private function getDefaultValue(): array{...}public function getValidationRules(?string Sprefix = null): array{...}CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role ManagemeWARN Metadata found iin doc-comment for method9/ 10 tacke done8 tiles/U ListenerkoiecannotHaveAaminurmanagerrermissionkuie.pnp +39apo/Comoonent/SClM/=Constants.ono +e/ u coreUser.phpapp/Component/SCIM/Mutators/Attributes/User/® RoleAttr.php +171ites/User/ RoleAttrTest.php +224ann/DTO/SCIMIAAD/Request/MCoreUserRequest.ohn t15Ask anvthina (*4D1+ <> Code SWE-1.6+0 ..inp on LineView all* Reject allAccent alllActivity:: TYPE_SOFTPHONE.Activity:: TYPE SOFTPHONE INBOUND.95@* @throws SocialAccountTokenInvalidexception* othrows Viminnu Exceotions HttoBadRequestExceptionathrows Jiminny Exceptions HttpForbiddenException• Othrows Waimannu ExcentionsHttoNotFoundExcentzonOthrows Jiminny Exceptions QuotaExceededExceptionAthrows Waiminnu ExcentionsServicellnavalahleExcentionpublic function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = nulU): ?intif (Sucen 1== null)d$this->logger->warning('User filter is not available with this provider');$this->hasSalesforceIntegrated = Sthis->team->crm->fields->where(['crm provider id' => self::CRM_CALL ID'object type' =>])->exists0:Sthis->cachedCrmServiceDecorator = resolve( name: CachedCrmServiceDecorator::class):wweAdAhodieamConthoic fdantaudeuta)hCall HierarchyW Windsurf Toams 05-21/10 charc) UTF.8 Al A enasoe...
|
55283
|
NULL
|
NULL
|
NULL
|
|
55285
|
1914
|
39
|
2026-05-18T13:59:57.151436+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112797151_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles• 0→Too FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:575Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:46:24...
|
NULL
|
-4965539153863625911
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles• 0→Too FirefoxFileEditViewHistoryBookmarksProfiles• 0→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:575Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:46:24...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55284
|
1914
|
38
|
2026-05-18T13:59:56.283155+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112796283_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
21
4
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":2,"on_screen":true,"role_description":"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"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":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","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\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-4305373452071923642
|
-1646255984498593726
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
21
4
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
55282
|
NULL
|
NULL
|
NULL
|
|
55283
|
1915
|
21
|
2026-05-18T13:59:52.969333+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112792969_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIeWINavicarecodeLaravelFV faVsco.js°9 mas PhostormVIeWINavicarecodeLaravelFV faVsco.js°9 master kProiect© ParentNode.php(C) CoreUserRequest.onp© RolesAndCrmOptionMismatchRule.php(c)SmsMessage.pnp© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnectJ Apollo> @J AvayaC BaseService95 Gt› _ bloobirasuCloseCloudcallCloudtalkConnectAndSellD DemoDeskDialpadl sichisvziahFiveNineN GmaillWiminny Services Activitv RinaCentr• Methodm d importbata service ...app/services/Activity RinaCen> Usages in Prolect Files 1 result* [Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:52C BaseService.pnp= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyc ScimProvisioning.ong© CoreUser.phpA console [STAGING]© CoachingFeedbackCoachUserln.php Xfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefini~public tunction -_construct(Userrepository suserkepository)l...© Activity/Close/service.pnp© Activity/RingCentral/Service.php x29 Oclass service extends Activityproviderservice impLements Importsbatalntertace mA21 M4 M1AV340>* acnrows. Jiminny.cxcepczons Heconocroundexcept10r* @throwsJiminny\Exceptions\QuotaExceededException= 444So)* dcnrows Jiminnu excepcions serviceunavazladlecxcepclonublic function importbata@carbon Sfrom = nulu, ?carbon Sto = nulu. ?User suser = null, ?stringif (suser == nulusthis->loqger-warnina'User filter 1s not avallable with this provider'oisthas->hassalesforceinteorated = sthis->team->crm->Freldso ->where oierm orovider id'= self::CRM CALL IDI=155136 @›'object_type' => 'task',1->existso:public function shouldApplyQueries: booll...,public function getQueries: FilterDefinitionQueryCollectiont...rpublic function toArray@: arrayt...private function getOptions): array{...}public function getValue: arrayi...}private function getDefaultValue(): array{...}public function getValidationRules(?string Sprefix = null): array{...}CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role ManagemeWARN Metadata found iin doc-comment for method9/ 10 tacke doneo files/U ListenerkoiecannotHaveAaminurmanagerrermissionkuie.pnp +39app/Comoonent/SCIM/=Constants.ono +3se/ b coreUser.phpann/Comnonent/SCIM/Mutatore/Attributes/Ucer/M PoleAtr.nbo 4171tes/User/ D RoleAttrTest.php +224ann/DTO/SCIMIAAD/Request/MCoreUserRequest.ohn t15Ask anvthina (*4D1+ <> Code SWE-1.6+0 ..inp on LineView all* Reject allAccent alll8U40»×95 €* @throws SocialAccountTokenInvalidSxcention* @throws Jiminny Exceptions HttpBadRequestException*AthrowsWaiminnuFxcentionc HttnForhiddenFycentzon* @throws Jiminny\Exceptions\HttpNotFoundException* @thnows Jiminny\Exceptions\QuotaExceededException* @thnows Jiminny\Exceptions|ServiceUnavailableExceptionpublic function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?intif (Suser !== null) {Sthis->logger->warning('User filter is not available with this provider'):Sthis->hasSalesforceIntegrated = Sthis->team->crm->fields( ->where(П'crm_provider id' => self::CRM_CALL_ID'object_type' => "task',1)->existsO:sthas->cachedermse/cebecorator = resolve(CachedCrmServicebecorator::class):Sthis->cachedCrmServiceDecorator->setCrmService(sthis->crmService)sif (Sactivitvld){$call = Sthis->cLient->getCallLoa(['view' => 'Detailed'1. Sactivitvid):if (Scall ee= null)dCall HierarchyW Windsurf Toams 05.21/10 charc)UTF.8# henssoe...
|
NULL
|
-3734705326483816792
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormVIeWINavicarecodeLaravelFV faVsco.js°9 mas PhostormVIeWINavicarecodeLaravelFV faVsco.js°9 master kProiect© ParentNode.php(C) CoreUserRequest.onp© RolesAndCrmOptionMismatchRule.php(c)SmsMessage.pnp© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnectJ Apollo> @J AvayaC BaseService95 Gt› _ bloobirasuCloseCloudcallCloudtalkConnectAndSellD DemoDeskDialpadl sichisvziahFiveNineN GmaillWiminny Services Activitv RinaCentr• Methodm d importbata service ...app/services/Activity RinaCen> Usages in Prolect Files 1 result* [Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:52C BaseService.pnp= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyc ScimProvisioning.ong© CoreUser.phpA console [STAGING]© CoachingFeedbackCoachUserln.php Xfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefini~public tunction -_construct(Userrepository suserkepository)l...© Activity/Close/service.pnp© Activity/RingCentral/Service.php x29 Oclass service extends Activityproviderservice impLements Importsbatalntertace mA21 M4 M1AV340>* acnrows. Jiminny.cxcepczons Heconocroundexcept10r* @throwsJiminny\Exceptions\QuotaExceededException= 444So)* dcnrows Jiminnu excepcions serviceunavazladlecxcepclonublic function importbata@carbon Sfrom = nulu, ?carbon Sto = nulu. ?User suser = null, ?stringif (suser == nulusthis->loqger-warnina'User filter 1s not avallable with this provider'oisthas->hassalesforceinteorated = sthis->team->crm->Freldso ->where oierm orovider id'= self::CRM CALL IDI=155136 @›'object_type' => 'task',1->existso:public function shouldApplyQueries: booll...,public function getQueries: FilterDefinitionQueryCollectiont...rpublic function toArray@: arrayt...private function getOptions): array{...}public function getValue: arrayi...}private function getDefaultValue(): array{...}public function getValidationRules(?string Sprefix = null): array{...}CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role ManagemeWARN Metadata found iin doc-comment for method9/ 10 tacke doneo files/U ListenerkoiecannotHaveAaminurmanagerrermissionkuie.pnp +39app/Comoonent/SCIM/=Constants.ono +3se/ b coreUser.phpann/Comnonent/SCIM/Mutatore/Attributes/Ucer/M PoleAtr.nbo 4171tes/User/ D RoleAttrTest.php +224ann/DTO/SCIMIAAD/Request/MCoreUserRequest.ohn t15Ask anvthina (*4D1+ <> Code SWE-1.6+0 ..inp on LineView all* Reject allAccent alll8U40»×95 €* @throws SocialAccountTokenInvalidSxcention* @throws Jiminny Exceptions HttpBadRequestException*AthrowsWaiminnuFxcentionc HttnForhiddenFycentzon* @throws Jiminny\Exceptions\HttpNotFoundException* @thnows Jiminny\Exceptions\QuotaExceededException* @thnows Jiminny\Exceptions|ServiceUnavailableExceptionpublic function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?intif (Suser !== null) {Sthis->logger->warning('User filter is not available with this provider'):Sthis->hasSalesforceIntegrated = Sthis->team->crm->fields( ->where(П'crm_provider id' => self::CRM_CALL_ID'object_type' => "task',1)->existsO:sthas->cachedermse/cebecorator = resolve(CachedCrmServicebecorator::class):Sthis->cachedCrmServiceDecorator->setCrmService(sthis->crmService)sif (Sactivitvld){$call = Sthis->cLient->getCallLoa(['view' => 'Detailed'1. Sactivitvid):if (Scall ee= null)dCall HierarchyW Windsurf Toams 05.21/10 charc)UTF.8# henssoe...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55282
|
1914
|
37
|
2026-05-18T13:59:53.260499+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112793260_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Searching for importData in 110 files…
Project: fa Searching for importData in 110 files…
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
21
4
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Searching for importData in 110 files…","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":2,"on_screen":true,"role_description":"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"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":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","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\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1516081236813873545
|
-1646255984498593726
|
visual_change
|
accessibility
|
NULL
|
Searching for importData in 110 files…
Project: fa Searching for importData in 110 files…
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
21
4
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55281
|
1914
|
36
|
2026-05-18T13:59:50.027724+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112790027_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistory→BookmarksProfilesToolsW FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 16:59:49A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:46:16...
|
NULL
|
5481202023529347957
|
NULL
|
visual_change
|
ocr
|
NULL
|
FirefoxFileEditViewHistory→BookmarksProfilesToolsW FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 16:59:49A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:46:16...
|
55279
|
NULL
|
NULL
|
NULL
|
|
55278
|
1915
|
19
|
2026-05-18T13:59:46.354815+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112786354_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Searching for importData in 110 files…
Project: fa Searching for importData in 110 files…
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
21
4
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Searching for importData in 110 files…","depth":2,"bounds":{"left":0.6449468,"top":0.92098963,"width":0.06948138,"height":0.011173184},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":2,"bounds":{"left":0.6449468,"top":0.952913,"width":0.06948138,"height":0.011173184},"on_screen":true,"role_description":"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":"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/>114 incoming commits<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":"AXStaticText","text":"21","depth":4,"bounds":{"left":0.38131648,"top":0.17478053,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.3929521,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.40292552,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"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.4192154,"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\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
1516081236813873545
|
-1646255984498593726
|
visual_change
|
accessibility
|
NULL
|
Searching for importData in 110 files…
Project: fa Searching for importData in 110 files…
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
21
4
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55277
|
1914
|
34
|
2026-05-18T13:59:43.977113+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112783977_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Searching for importData in 110 files…
Project: fa Searching for importData in 110 files…
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
21
4
1
Previous Highlighted Error
Next Highlighted Error...
|
[{"role":"AXStaticText","text& [{"role":"AXStaticText","text":"Searching for importData in 110 files…","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"","depth":2,"bounds":{"left":0.7826389,"top":0.0,"width":0.14513889,"height":0.015555556},"on_screen":true,"role_description":"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"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":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","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}]...
|
1448049054652375250
|
-8744856731639485502
|
visual_change
|
hybrid
|
NULL
|
Searching for importData in 110 files…
Project: fa Searching for importData in 110 files…
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
21
4
1
Previous Highlighted Error
Next Highlighted Error
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 16:59:43)5Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:46:10...
|
55275
|
NULL
|
NULL
|
NULL
|
|
55276
|
1915
|
18
|
2026-05-18T13:59:42.493332+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112782493_m2.jpg...
|
PhpStorm
|
Find Usages
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Find Usages
text/html
text/html
text/html
Method & Find Usages
text/html
text/html
text/html
Method 'importData()' of class 'Service' implements method in 'ImportsDataInterface'<br/>Do you want to find usages of base method?
Don't ask again
Cancel
Current Method
Base Method...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Find Usages","depth":1,"bounds":{"left":0.44481382,"top":0.28092578,"width":0.124667555,"height":0.014365523},"on_screen":true,"value":"Find Usages","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":2,"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":2,"bounds":{"left":0.44481382,"top":0.28092578,"width":0.027593086,"height":0.014365523},"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":2,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Method 'importData()' of class 'Service' implements method in 'ImportsDataInterface'<br/>Do you want to find usages of base method?","depth":1,"bounds":{"left":0.44481382,"top":0.30167598,"width":0.124667555,"height":0.040702313},"on_screen":true,"help_text":"text/html","role_description":"text"},{"role":"AXCheckBox","text":"Don't ask again","depth":1,"bounds":{"left":0.44348404,"top":0.3519553,"width":0.12599733,"height":0.019952115},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel","depth":1,"bounds":{"left":0.44481382,"top":0.38707104,"width":0.028590426,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Current Method","depth":1,"bounds":{"left":0.47739363,"top":0.38707104,"width":0.04720745,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Base Method","depth":1,"bounds":{"left":0.52859044,"top":0.38707104,"width":0.04089096,"height":0.027134877},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8826040943620210519
|
8402355453214726924
|
click
|
accessibility
|
NULL
|
Find Usages
text/html
text/html
text/html
Method & Find Usages
text/html
text/html
text/html
Method 'importData()' of class 'Service' implements method in 'ImportsDataInterface'<br/>Do you want to find usages of base method?
Don't ask again
Cancel
Current Method
Base Method...
|
55273
|
NULL
|
NULL
|
NULL
|
|
55275
|
1914
|
33
|
2026-05-18T13:59:40.959772+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112780959_m1.jpg...
|
PhpStorm
|
Find Usages
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Find Usages
text/html
text/html
text/html
Method & Find Usages
text/html
text/html
text/html
Method 'importData()' of class 'Service' implements method in 'ImportsDataInterface'<br/>Do you want to find usages of base method?
Don't ask again
Cancel
Current Method
Base Method...
|
[{"role":"AXTextField","text [{"role":"AXTextField","text":"Find Usages","depth":1,"on_screen":true,"value":"Find Usages","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":2,"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":2,"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":2,"on_screen":false,"help_text":"text/html","role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Method 'importData()' of class 'Service' implements method in 'ImportsDataInterface'<br/>Do you want to find usages of base method?","depth":1,"on_screen":true,"help_text":"text/html","role_description":"text"},{"role":"AXCheckBox","text":"Don't ask again","depth":1,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Current Method","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Base Method","depth":1,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8826040943620210519
|
8402355453214726924
|
visual_change
|
accessibility
|
NULL
|
Find Usages
text/html
text/html
text/html
Method & Find Usages
text/html
text/html
text/html
Method 'importData()' of class 'Service' implements method in 'ImportsDataInterface'<br/>Do you want to find usages of base method?
Don't ask again
Cancel
Current Method
Base Method...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55274
|
1914
|
32
|
2026-05-18T13:59:37.557152+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112777557_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"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}]...
|
-5794999150126922245
|
-8204417045355058234
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 16:59:37A05Galya Dimitrova,Nikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:46:04...
|
55272
|
NULL
|
NULL
|
NULL
|
|
55273
|
1915
|
17
|
2026-05-18T13:59:37.544011+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112777544_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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...
|
[{"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":"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/>114 incoming commits<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}]...
|
-4235983745889776938
|
-8204421443435123770
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master kroledeyC ActivityController.ong© SoftPhoneManager.php= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euy© ParentNode.php© RolesAndCrmOptionMismatchRule.php©) Smsmessage.pnp© TransferUserAdminRule.phpv m Servicesv @ Activity>@ Aircall>@ AmazonConnect_Apollo> @J AvayaC BaseService>_ bloobirasuCloseCloudcallCloudtalkConnectAndSellD DemoDeskDialpadD EightByEightFiveNineM GmaillD Gong• GoogleMeet• GoToMeetingM GrooveM LuhSootImportJiminnyaluctCallD MigratorNatterbox0 Office0 OrumC Outreach• C RingCentral© Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoC7 SalesforceSales oftTalkdeskM TeamsTelusn Twilion TwilloflexM TwiloflexDirectM TwilioVidenM Uinloade"N VonaadM Xanti> M7oom(C) CoreUserRequest.onpscimProvistoning.ong© CoreUser.phpA console [STAGING]© CoachingFeedbackCoachUserln.php X<?php© Activity/Close/service.php© Activity/RingCentral/Service.php xdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*class Service extends Activityproviderservice impLements Importsbataintertace m| A21 ×411 ^ v42 Ci 047 053 0101)58 0163 01170 0195 €102.104private const CALL CACHE CONCURRENCY TIMEOUT = 10; // 10 seconds› use ...s usagesprivate const CALL CACHE COMPLETED = 'completed' :final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterfaceprivate const CALL CACHE COMPLETED TIMEOUT = 3600: // 1 hour1usadeorivate const int No GROUP 10 = 999procected scring sname = ACCIVICY::PRUVIUEK KLNGCENIRAL"private bool ShasSalesforceIntegrated = false:NANorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hprivate CachedCrmServiceDecorator $cachedCrmServicebecorator;29 Ф >public function shouldApplyQueries: boolf...}public function __construct(?array $config = []){...}34 6>public function getQueries: FilterDefinitionQueryCollection...hpublic function getDisplayName: stringt...45 đ >public function toArray@: array{...hpublic function getinstallationStrategy: stringt...}private function getOptions(): array{...}public function onActivityProviderIntegrated: void...}— 118=119public function getValue®: array{...}public function supportedActivityChannels: arrayt...* Athrows SocialAccountTokenTnvalidFycention=131 >135=136 Gt >private function getDefaultValue: array{...}* @throws Jiminny\Exceptions\HttpBadRequestException* @throwsJiminny\Exceptions\HttpForbiddenException* @throws Jiminny\Exceptions\HttpNotFoundExceptionpublic function aetValidationRules(2strina Sorefix = null): arrav{...?l* acnnows. Jiminny.cxcepczons uuocacxceededexcepc1on* acnrows Jiminnu excepcions serviceunavazladlecxcepcion146 Gt ›150— 151 G;public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string 156if (Suser !== null) {Sthis->loqger->warning('User filter is not available with this provider'):Sthis->hasSalesforceIntegrated = Sthis->team->crm->fields()->where(['erm provider id' = self:-CRM CALL 1D.1->existso:sthic->cachedcrmServicpleconaton =npcolvedname:CachedtnmServzcpheconaton..clacs)cthie-scachedcrmServiceleconaton-scottrmServicecthic->crmServicp[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:37CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Teams107-12UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55272
|
1914
|
31
|
2026-05-18T13:59:35.867633+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112775867_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, 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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7429716278976468786
|
-8636355650190325311
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:35)A05Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:46:02...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55271
|
1915
|
16
|
2026-05-18T13:59:33.647653+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112773647_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormVIewINavicarecodeLaravelRefactorFV faVsco. PhpStormVIewINavicarecodeLaravelRefactorFV faVsco.js°9 master k >ProiectC ActivityController.ongYC) ParentNode.ohr(C) CoreUserRequest.onp© RolesAndCrmOptionMismatchRule.phpc)SmsMessage.one© TransferUserAdminRule.phpv m Servicesv @ Activity>@ Aircall>@ AmazonConnect_Apollo> @J AvayaC BaseService>_ bloobirasuClose07 CloudCallCloudtalkConnectAndSellD DemoDeskDialpad0 EightByEightFiveNineM GmaiD Gong• GoogleMeet• GoToMeetingM GrooveM LuhSootImportJiminnyaluctCallD MigratorNatterboxOffino0 Orumm OutreachC RingCentral© Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoC7 SalesforceSales oftTalkdeskM TeamsTelusM Twilion TwilloflexM TwilioflexDirectM TwilioVidenM Uinloade"N VonaadM Xanti• M7oom58 €630790110|121C BaseService.php© SoftPhoneManager.phpscimProvistoning.ong© CoreUser.php© Activity/RingCentral/Service.php Xclass service extends Activityproviderservice impLements Importsbataintertace m A21 M 4 .11 ^ Vpublic function getInstallationStrategyO: string{...}public function onActivityProviderIntegrated(): void(...}public function supportedactiv1tychannelso: arrav-...* @throws SocialAccountTokeninvauidExcent.ion* dthrowsWiminnu Exceotions HttoBadRequestExcention* dthrowsWiminnu Excentions HttoForbiddenExcent.ion* dthrowsWiminnu Excentions HttoNotFoundExcent.ion* Othrows Jiminny Exceptions QuotaExceededException* Othrows Jiminny Exceptions ServiceUnavailableExceptionpublic function importData(?Carbon $from = null, ?Carbon $to = null, ?User Suser = null, ?stringif (Suser !== null) {$this->logger->warning("User filter is not available with this provider');Sthis->hasSalesforceInteqrated = Sthis->team->crm->fields( ->where([crm provlder10 → selt..cKM CALL_1U.'obnect type' = "task',])->existsosth1s->cachedcrmservicebecorator = resolve@name: Cachedcrmservicebecorator::class):sth1s->cachedcrmserv1cebecorator->setcrmserv1cesth1s->crmserv1ceif (Sactivitvid) &$call = Sthis->client-›qetCallLoa(f'view' => 'Detailed']. Sactivitvld).if (Scall =e= nulb <return 0:$this->logger->debug('[RingCentral) ApiResponse', $call):neturn(int)_Sthis->imnon+callCall.•croateFromPauload/Scall))* every XX:15, XX:30, and XX:45 we look back 1 hour,* while at every round hour XX:00 we look back 6 hours= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch Filterder1n1t1on:› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 đ >45 đ >118135136 @t›146 C>151 G>1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray@: array{...;private function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue@: array{...}public function getValidationRules(2string Sprefix = null): arrav{...}lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:33CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Toams 107-17 UTF.8io 4 spaces...
|
NULL
|
-7360107536640100479
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormVIewINavicarecodeLaravelRefactorFV faVsco. PhpStormVIewINavicarecodeLaravelRefactorFV faVsco.js°9 master k >ProiectC ActivityController.ongYC) ParentNode.ohr(C) CoreUserRequest.onp© RolesAndCrmOptionMismatchRule.phpc)SmsMessage.one© TransferUserAdminRule.phpv m Servicesv @ Activity>@ Aircall>@ AmazonConnect_Apollo> @J AvayaC BaseService>_ bloobirasuClose07 CloudCallCloudtalkConnectAndSellD DemoDeskDialpad0 EightByEightFiveNineM GmaiD Gong• GoogleMeet• GoToMeetingM GrooveM LuhSootImportJiminnyaluctCallD MigratorNatterboxOffino0 Orumm OutreachC RingCentral© Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoC7 SalesforceSales oftTalkdeskM TeamsTelusM Twilion TwilloflexM TwilioflexDirectM TwilioVidenM Uinloade"N VonaadM Xanti• M7oom58 €630790110|121C BaseService.php© SoftPhoneManager.phpscimProvistoning.ong© CoreUser.php© Activity/RingCentral/Service.php Xclass service extends Activityproviderservice impLements Importsbataintertace m A21 M 4 .11 ^ Vpublic function getInstallationStrategyO: string{...}public function onActivityProviderIntegrated(): void(...}public function supportedactiv1tychannelso: arrav-...* @throws SocialAccountTokeninvauidExcent.ion* dthrowsWiminnu Exceotions HttoBadRequestExcention* dthrowsWiminnu Excentions HttoForbiddenExcent.ion* dthrowsWiminnu Excentions HttoNotFoundExcent.ion* Othrows Jiminny Exceptions QuotaExceededException* Othrows Jiminny Exceptions ServiceUnavailableExceptionpublic function importData(?Carbon $from = null, ?Carbon $to = null, ?User Suser = null, ?stringif (Suser !== null) {$this->logger->warning("User filter is not available with this provider');Sthis->hasSalesforceInteqrated = Sthis->team->crm->fields( ->where([crm provlder10 → selt..cKM CALL_1U.'obnect type' = "task',])->existsosth1s->cachedcrmservicebecorator = resolve@name: Cachedcrmservicebecorator::class):sth1s->cachedcrmserv1cebecorator->setcrmserv1cesth1s->crmserv1ceif (Sactivitvid) &$call = Sthis->client-›qetCallLoa(f'view' => 'Detailed']. Sactivitvld).if (Scall =e= nulb <return 0:$this->logger->debug('[RingCentral) ApiResponse', $call):neturn(int)_Sthis->imnon+callCall.•croateFromPauload/Scall))* every XX:15, XX:30, and XX:45 we look back 1 hour,* while at every round hour XX:00 we look back 6 hours= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch Filterder1n1t1on:› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 đ >45 đ >118135136 @t›146 C>151 G>1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray@: array{...;private function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue@: array{...}public function getValidationRules(2string Sprefix = null): arrav{...}lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:33CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabled•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Toams 107-17 UTF.8io 4 spaces...
|
55268
|
NULL
|
NULL
|
NULL
|
|
55270
|
1914
|
30
|
2026-05-18T13:59:33.623146+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112773623_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistory→BookmarksProfilesToolsW FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:33)40 5Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:46:00...
|
NULL
|
-6434945452674660002
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistory→BookmarksProfilesToolsW FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:33)40 5Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:46:00...
|
55269
|
NULL
|
NULL
|
NULL
|
|
55269
|
1914
|
29
|
2026-05-18T13:59:32.864715+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112772864_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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}]...
|
-4235983745889776938
|
-8204421443435123770
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:3220 5••Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:45:59...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55268
|
1915
|
15
|
2026-05-18T13:59:30.382794+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112770382_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
21
4
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
[{"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":"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/>114 incoming commits<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":"AXStaticText","text":"21","depth":4,"bounds":{"left":0.38131648,"top":0.17478053,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.3929521,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.40292552,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4119016,"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.4192154,"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\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
-4305373452071923642
|
-1646255984498593726
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
21
4
1
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55267
|
1914
|
28
|
2026-05-18T13:59:29.864370+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112769864_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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}]...
|
-3088599111841370244
|
-8636775528843997756
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:295Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:45:56...
|
55265
|
NULL
|
NULL
|
NULL
|
|
55266
|
1915
|
14
|
2026-05-18T13:59:24.261615+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112764261_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
|
[{"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}]...
|
8043719072324535154
|
-8628527368849355612
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
PhostormVIewINavicarecode Project: faVsco.js, menu
PhostormVIewINavicarecodeLaravelRefactorFV faVsco.js°9 master k >ProiectC ActivityController.ongYC) ParentNode.ohrC) CoreUserRequest.pnp© RolesAndCrmOptionMismatchRule.phpc)SmsMessage.one© TransferUserAdminRule.phpv m Servicesv @ Activity>@ Aircall>@ AmazonConnect_ Apollo> @J AvayaC BaseService>_ bloobirasu Close07 CloudCallCloudtalkConnectAndSellD DemoDeskDialpad0 EightByEightFiveNineM GmaiD Gong• GoogleMeet• GoToMeetingM GrooveM LuhSootImportJiminnyaluctCallD MigratorNatterboxOffino0 Orumm OutreachC RingCentral© Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoC7 SalesforceSales oftTalkdeskM TeamsTelusM Twilion TwilloflexM TwilioflexDirectM TwilioVidenM Uinloade"N VonaadM Xanti• M7oom58 €63095 C888889205C BaseService.php© SoftPhoneManager.phpscimProvistoning.ong© CoreUser.php© Activity/RingCentral/Service.php Xclass service extends Activityproviderservice impLements Importsbataintertace m A21 M 4 .11 ^ Vpublic function getInstallationStrategyO: string{...}public function onActivityProviderIntegrated(): void(...}public function supportedactiv1tychannelso: arrav-...* @throws SocialAccountTokeninvauidExcent.ion* dthrowsWiminnu Exceotions HttoBadRequestExcention* dthrowsWiminnu Excentions HttoForbiddenExcent.ion* dthrowsWiminnu Excentions HttoNotFoundExcent.ion* Othrows Jiminny Exceptions QuotaExceededException* Othrows Jiminny Exceptions ServiceUnavailableExceptionpublic function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?stringif (Suser !== null) {$this->logger->warning("User filter is not available with this provider');Sthis->hasSalesforceInteqrated = Sthis->team->crm->fields( ->where([crm provlder10 → selt..cKM CALL_1U.'obnect type => "task])->existsoSthis->cachedCrmServiceDecorator = resolve(namCachedcrmservicebecorator::class)sth1s->cachedcrmserv1cebecorator->setcrmserv1cesth1s->crmserv1cerif (Sactivitvid) &$call = Sthis->client-›qetCallLoa(f'view' => 'Detailed'1. SactivitvId):if (Scall =e= nulb <return 0:sthis->loagen->dehua/*fRinaCentnal)AniResnonse' Scalla-neturn(int) Sthis-simnontcalldCall.•createEromPauload/Scall))e* every XX:15, XX:30, and XX:45 we look back 1 hour,* while at every round hour XX:00 we look back 6 hours= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php Xstohedeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch Filterder1n1t1on:› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 đ >45 đ >135136 @t›146 C>151 G>1usadeorivate const int No GROUP 10 = 999orivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection...hpublic function toArray@: array{...hprivate function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue@: array{...}public function getValidationRules(2string Sprefix = null): arrav{...}lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:23CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 05-127 UTF.8io 4 spaces...
|
55264
|
NULL
|
NULL
|
NULL
|
|
55265
|
1914
|
27
|
2026-05-18T13:59:23.778748+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112763778_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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}]...
|
-4235983745889776938
|
-8204421443435123770
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Firefox• 0FileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:23)5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:45:50...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55264
|
1915
|
13
|
2026-05-18T13:59:19.708445+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112759708_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master kroledeyC ActivityController.ongC.BaseService.onp© SoftPhoneManager.phpC) ParentNode.onp(C) CoreUserRequest.onpc ScimProvisioning.ong© CoreUser.php© RolesAndCrmOptionMismatchRule.phpc) RoleAttrTest.pngC) Smsmessage.pnp© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnectJ Apollo> @J AvayaC BaseService› _ bloobirasuCloseCloudcallCloudtalkConnectAndSellDemoDeskDialpadl sichisvziahFiveNineM GmaiD GongM GoogleMeetGoToMeetingN Groovem LuhSootImportJiminnyaluctCallD Migratorh Natterhay0 Office0 OrumC Outreach• C RingCentralc) Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoSalestorce• Sales oftTalkdeskM TeamsTelusM Twilidn Twilloflex• M TwilioflexDirectM TwilioVidenIm Uinloade'N VonaadM Xanti• M7oom© Activity/Close/service.pnp© Activity/RingCentral/Service.php Xclass Service extends Activityproviderservice impLements Importsbataintertace m A21 M 4 .11 ^ Vrcsfl__CALL_UNIQUE_ID__C';URRENCY = 'concurrency';URRENCYTRMEOUT = 10; // 10 secondsLETED = 'completed';LETED TIMEOUT = 3600; // 1 houn42 @0vity::PROVIDER_RINGCENTRAL;cecrared = ralse:ator scachedcrmservicebecorator:47 ₫array Sconfig = uDk...}5sono eo: strino...?58onStrateav@: stringf...;viderIntegrated@: voids...?• vitvchannelso• arrav....?InvalidExcentionsWttnBadReauestExcent.ionsHttpForbiddenExceptioncHttnNo+FoundExcentions\QuotaExceededExceptions|ServiceUnavailableException95 €> arbon $from = null, ?Carbon $to = null, ?User $user = null, ?string SactivityId = null): ?int{...}> all Scall): bool{...}> sions(Call $call): arravf...}= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php X<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >-119131_135_136 Gt >— 146 Gt ›150151 G>1usadeorivate const int No GroUp 10 = 999.3 usagesorivate UserRenository suserRenository:public function__construct(UserRepository $userRepository)1...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...}public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:19CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ucer/M PoleAttr.nho 417ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Toams 22-7UTF.8io 4 spaces...
|
NULL
|
7626117978519251189
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js°9 master kroledeyC ActivityController.ongC.BaseService.onp© SoftPhoneManager.phpC) ParentNode.onp(C) CoreUserRequest.onpc ScimProvisioning.ong© CoreUser.php© RolesAndCrmOptionMismatchRule.phpc) RoleAttrTest.pngC) Smsmessage.pnp© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnectJ Apollo> @J AvayaC BaseService› _ bloobirasuCloseCloudcallCloudtalkConnectAndSellDemoDeskDialpadl sichisvziahFiveNineM GmaiD GongM GoogleMeetGoToMeetingN Groovem LuhSootImportJiminnyaluctCallD Migratorh Natterhay0 Office0 OrumC Outreach• C RingCentralc) Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoSalestorce• Sales oftTalkdeskM TeamsTelusM Twilidn Twilloflex• M TwilioflexDirectM TwilioVidenIm Uinloade'N VonaadM Xanti• M7oom© Activity/Close/service.pnp© Activity/RingCentral/Service.php Xclass Service extends Activityproviderservice impLements Importsbataintertace m A21 M 4 .11 ^ Vrcsfl__CALL_UNIQUE_ID__C';URRENCY = 'concurrency';URRENCYTRMEOUT = 10; // 10 secondsLETED = 'completed';LETED TIMEOUT = 3600; // 1 houn42 @0vity::PROVIDER_RINGCENTRAL;cecrared = ralse:ator scachedcrmservicebecorator:47 ₫array Sconfig = uDk...}5sono eo: strino...?58onStrateav@: stringf...;viderIntegrated@: voids...?• vitvchannelso• arrav....?InvalidExcentionsWttnBadReauestExcent.ionsHttpForbiddenExceptioncHttnNo+FoundExcentions\QuotaExceededExceptions|ServiceUnavailableException95 €> arbon $from = null, ?Carbon $to = null, ?User $user = null, ?string SactivityId = null): ?int{...}> all Scall): bool{...}> sions(Call $call): arravf...}= custom.log= laravel.log« SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php X<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >-119131_135_136 Gt >— 146 Gt ›150151 G>1usadeorivate const int No GroUp 10 = 999.3 usagesorivate UserRenository suserRenository:public function__construct(UserRepository $userRepository)1...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}lprivate function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...}public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:19CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ucer/M PoleAttr.nho 417ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Toams 22-7UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55263
|
1914
|
26
|
2026-05-18T13:59:19.726225+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112759726_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Firefox• 0FileEdit→ViewHistoryBookmarksProfilesToo Firefox• 0FileEdit→ViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 16:59:195Galya DimitroveNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:45:46...
|
NULL
|
3755345842248382623
|
NULL
|
click
|
ocr
|
NULL
|
Firefox• 0FileEdit→ViewHistoryBookmarksProfilesToo Firefox• 0FileEdit→ViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 16:59:195Galya DimitroveNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:45:46...
|
55262
|
NULL
|
NULL
|
NULL
|
|
55262
|
1914
|
25
|
2026-05-18T13:59:16.950656+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112756950_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/RingCentral/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
7
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"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":"7","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\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Activity\\RingCentral;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse JsonException;\nuse Jiminny\\Contracts\\Services\\ImportsDataInterface;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Call;\nuse Jiminny\\DTO\\ImportCall\\RingCentral\\Extension;\nuse Jiminny\\Events\\Activities\\Dialers\\CallImported;\nuse Jiminny\\Events\\Activities\\Provider\\SyncActivityProviderEvent;\nuse Jiminny\\Exceptions\\RingCentralExtensionNotFound;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Activity\\RingCentral\\DownloadTrack;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Participant\\Connection;\nuse Jiminny\\Models\\Track;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Services\\Activity\\ActivityProviderService;\nuse Jiminny\\Services\\Activity\\RingCentral\\Client as RingCentralClient;\nuse Jiminny\\Services\\Crm\\CachedCrmServiceDecorator;\nuse Sentry\\Laravel\\Facade as Sentry;\nuse Throwable;\n\nclass Service extends ActivityProviderService implements ImportsDataInterface\n{\n private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';\n private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';\n\n private const CALL_CACHE_CONCURRENCY = 'concurrency';\n private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds\n private const CALL_CACHE_COMPLETED = 'completed';\n private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour\n\n protected string $name = Activity::PROVIDER_RINGCENTRAL;\n private bool $hasSalesforceIntegrated = false;\n\n private CachedCrmServiceDecorator $cachedCrmServiceDecorator;\n\n public function __construct(?array $config = [])\n {\n parent::__construct();\n $this->client = new RingCentralClient($config);\n }\n\n public function getDisplayName(): string\n {\n return 'RingCentral';\n }\n\n public function getInstallationStrategy(): string\n {\n return self::INSTALLATION_STRATEGY_OAUTH;\n }\n\n public function onActivityProviderIntegrated(): void\n {\n if (! $this->hasProvider()) {\n $this->setupActivityProvider();\n }\n\n $this->setSocialAccount(null);\n $response = $this->client->get('/restapi/v1.0/account/~');\n $this->provider->update(['provider_id' => $response['id'] ?? null]);\n\n event(new SyncActivityProviderEvent($this->getProvider(), [\n 'start_date' => now()->subMonth(),\n 'end_date' => now(),\n ]));\n }\n\n public function supportedActivityChannels(): array\n {\n return [\n Activity::TYPE_SOFTPHONE,\n Activity::TYPE_SOFTPHONE_INBOUND,\n ];\n }\n\n /**\n * @throws SocialAccountTokenInvalidException\n * @throws \\Jiminny\\Exceptions\\HttpBadRequestException\n * @throws \\Jiminny\\Exceptions\\HttpForbiddenException\n * @throws \\Jiminny\\Exceptions\\HttpNotFoundException\n * @throws \\Jiminny\\Exceptions\\QuotaExceededException\n * @throws \\Jiminny\\Exceptions\\ServiceUnavailableException\n */\n public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int\n {\n if ($user !== null) {\n $this->logger->warning('User filter is not available with this provider');\n }\n\n $this->hasSalesforceIntegrated = $this->team->crm->fields()->where([\n 'crm_provider_id' => self::CRM_CALL_ID,\n 'object_type' => 'task',\n ])->exists();\n\n $this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);\n $this->cachedCrmServiceDecorator->setCrmService($this->crmService);\n\n if ($activityId) {\n $call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);\n\n if ($call === null) {\n return 0;\n }\n\n $this->logger->debug('[RingCentral] ApiResponse', $call);\n\n return (int) $this->importCall(Call::createFromPayload($call));\n }\n\n /**\n * every XX:15, XX:30, and XX:45 we look back 1 hour,\n * while at every round hour XX:00 we look back 6 hours\n */\n $lookupCandidatePeriod = now()->subHour();\n if ($from === null || $from->isAfter($lookupCandidatePeriod)) {\n $from = $lookupCandidatePeriod;\n }\n\n if ($to === null) {\n $to = now();\n }\n\n $syncCount = 0;\n $offset = 1;\n $payload = [\n 'perPage' => 1000,\n 'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),\n 'dateTo' => $to->format(DATE_RFC3339_EXTENDED),\n 'type' => ['Voice'],\n 'view' => 'Detailed',\n ];\n\n do {\n $payload['page'] = $offset;\n\n try {\n $response = $this->client->getCallLog($payload);\n\n if ($response === null) {\n return $syncCount;\n }\n\n $this->logger->info(\n '[RingCentral] Retrieved call log',\n [\n 'teamId' => $this->team->id_string,\n 'totalCalls' => count($response['records']),\n 'payload' => $payload,\n ]\n );\n\n foreach ($response['records'] as $call) {\n $ringCentralCall = Call::createFromPayload($call);\n\n try {\n $syncCount += (int) $this->importCall($ringCentralCall);\n } catch (RingCentralExtensionNotFound $exception) {\n $this->logger->warning('RingCentral extension not found', [\n 'team' => $this->team->id_string,\n 'extension' => $exception->getExtensionId(),\n ]);\n\n Sentry::captureException($exception);\n }\n }\n } catch (Throwable $exception) {\n $this->logger->error('[RingCentral] Import has failed', [\n 'team' => $this->team->getSlug(),\n 'socialAccountId' => $this->socialAccount->getId(),\n 'code' => $exception->getCode(),\n 'exception' => $exception->getMessage(),\n ]);\n\n return $syncCount;\n }\n\n $offset++;\n } while (isset($response['navigation']['nextPage']));\n\n $this->provider->update(['last_synced_at' => now()]);\n\n return $syncCount;\n }\n\n private function importCall(Call $call): bool\n {\n $callId = $call->getCallId();\n\n if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)\n || $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))\n && ! $this->debugMode\n ) {\n $this->logger->info('[RingCentral] Call already imported', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);\n\n $callData = $call->getPayload();\n $isOutbound = $call->isDirectionOutbound();\n $playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;\n $fields = $task = [];\n\n $this->logger->info('[RingCentral] Syncing call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n 'callData' => $callData,\n ]);\n\n if ($this->isInternalCall($call)) {\n $this->logger->info('[RingCentral] Skipping internal call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! $call->isRecordingAvailable()) {\n $this->logger->warning('[RingCentral] Call has no recording', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n if (! isset($callData['from'], $callData['to'])) {\n $this->logger->warning('[RingCentral] Call is missing from or to data', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $user = $this->resolveUser($call);\n\n if (! $user instanceof User) {\n $this->logger->info('[RingCentral] No user found. Skipping call', [\n 'callId' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $existingCall = $user->activities()\n ->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n $callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);\n\n if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {\n $this->logger->info('[RingCentral] Call already exists', [\n 'call_id' => $callId,\n 'teamId' => $this->team->getId(),\n ]);\n\n return false;\n }\n\n $phone = $isOutbound\n ? $call->getCalleePhoneNumber()\n : $call->getCallerPhoneNumber();\n\n $title = 'Call ' . ($isOutbound ? 'to ' : 'from ');\n $playbook = $this->getPlaybook($user);\n\n if ($this->hasSalesforceIntegrated) {\n try {\n $fields = $this->team->crm->fields()->where('is_indexable', 1)\n ->where('object_type', 'task')->get()->all();\n\n $crmFilters = [\n self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',\n ];\n\n $task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);\n } catch (Exception $ex) {\n Sentry::captureException($ex);\n }\n }\n\n if (! empty($task) && $this->hasSalesforceIntegrated) {\n [$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);\n\n if ($task['Type']) {\n $playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);\n }\n } elseif ($phone !== null) {\n // Note in some cases phone might be null.\n [$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(\n $phone,\n null,\n $user->getId()\n );\n }\n\n if ($lead === null && $contact === null && $account === null) {\n $this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [\n 'person' => $callData['to'],\n ]);\n\n $title .= $phone;\n } else {\n $title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));\n }\n\n $startedAt = $call->getActualStartTime();\n $completedAt = $call->getActualEndTime();\n $duration = $call->getDuration();\n\n /** @var Activity|null $activity */\n $activity = $user->activities()->withTrashed()\n ->where('telephony_provider_id', $callId)\n ->where('provider', $this->getName())\n ->first();\n\n if ($activity?->trashed()) {\n $this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);\n\n return false;\n }\n\n $type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;\n $hasRecordingPrompt = false;\n\n if (\n ! $isOutbound\n && ! empty($this->provider->capabilities)\n && isset($this->provider->capabilities['HasRecordingPrompt'])\n ) {\n $hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];\n }\n\n $status = $this->mapStatus($callData['result']);\n $recordingReasonCode = $status === Activity::STATUS_FAILED ?\n Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;\n\n /** @var Activity $activity */\n $activity = $user->activities()->updateOrCreate([\n 'telephony_provider_id' => $callId,\n 'provider' => $this->getName(),\n ], [\n 'type' => $type,\n 'playbook_category_id' => $playbookCategory->id ?? null,\n 'crm_configuration_id' => $this->team->crm_id,\n 'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID\n 'actual_start_time' => $startedAt,\n 'scheduled_start_time' => $startedAt,\n 'actual_end_time' => $completedAt,\n 'scheduled_end_time' => $completedAt,\n 'title' => $task['subject'] ?? $title,\n 'lead_id' => $lead->id ?? null,\n 'contact_id' => $contact->id ?? null,\n 'account_id' => $account->id ?? null,\n 'opportunity_id' => $opportunity->id ?? null,\n 'stage_id' => $stage->id ?? null,\n 'status' => $status,\n 'duration' => $duration,\n 'recording_state' => Activity::RECORDING_RECORDED,\n 'recording_reason_code' => $recordingReasonCode,\n 'is_internal' => false,\n 'description' => $task['summary'] ?? null,\n 'has_recording_prompt' => $hasRecordingPrompt,\n ]);\n\n $this->emitProspectAddedIfNecessary($activity, $this->getName());\n\n $this->logger->info(\n '[RingCentral] Activity finished processing',\n [\n 'activityId' => $activity->id_string,\n 'callId' => $callId,\n 'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',\n ],\n );\n\n $this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);\n $this->updateCrmFieldData($activity, $task, $fields);\n\n $this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);\n $organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);\n $organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);\n $customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);\n $this->createProspectParticipantConnection($callData, $organiserParticipant);\n\n $activity->update([\n 'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,\n 'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,\n ]);\n\n $this->purgeCorruptedTracks($activity);\n\n $track = $this->createTrack($callData, $activity, $organiserConnection);\n\n $this->logger->info('[RingCentral] Dispatching track download event', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'callNeedsUpdate' => $callNeedsUpdate,\n ]);\n\n dispatch(new DownloadTrack(\n trackId: $track->id_string,\n socialAccountId: $this->socialAccount->id,\n debugMode: $this->debugMode || $callNeedsUpdate\n ));\n\n event(new CallImported($activity));\n\n $this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);\n\n return $activity->wasRecentlyCreated || $activity->isDirty();\n }\n\n private function resolveExtensions(Call $call): array\n {\n $extensions = [];\n $foundExtensionIds = [];\n\n foreach (array_reverse($call->getLegs()) as $leg) {\n $extensionId = array_get($leg, 'extension.id');\n\n if ($extensionId === null) {\n continue;\n }\n\n $extensionId = (string) $extensionId;\n\n $hasExtension = $this->getProvider()->providerUsers()\n ->where('external_id', $extensionId)\n ->exists();\n\n if ($hasExtension) {\n $extensions[] = $extensionId;\n } else {\n $foundExtensionIds[] = $extensionId;\n }\n }\n\n if (! empty($foundExtensionIds)) {\n $this->logger->info(\n '[RingCentral] Some extensions were not found in db, resolving from API...',\n ['foundExtensionIds' => $foundExtensionIds]\n );\n\n $foundExtensionIds = array_unique($foundExtensionIds);\n\n foreach ($foundExtensionIds as $extensionId) {\n $remoteUser = $this->resolveExtensionFromApi($extensionId);\n $this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);\n\n if ($remoteUser !== null) {\n $this->logger->info(\"[RingCentral] syncing remote user {$remoteUser->getId()}\");\n $this->syncUserExtension($remoteUser);\n $extensions[] = (string) $remoteUser->getId();\n }\n }\n }\n\n $sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);\n\n // Extension assigned by RingCentral has top priority\n $extensionId = $call->getExtensionId();\n if (! empty($extensionId)) {\n $sortedExtensions->prepend((string) $extensionId);\n }\n\n return $sortedExtensions->unique()->values()->toArray();\n }\n\n private function sortExtensionsByStatus(array $extensions, Call $call): Collection\n {\n $statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];\n $reversedLegs = collect($call->getLegs())->reverse();\n\n return collect($extensions)\n ->map(function ($extension) use ($reversedLegs) {\n $status = $reversedLegs\n ->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)\n ['result'] ?? null;\n\n return ['extension' => $extension, 'result' => $status];\n })\n ->sortBy(function ($item) use ($statusOrder) {\n $index = array_search($item['result'], $statusOrder, true);\n\n return $index !== false ? $index : PHP_INT_MAX;\n })\n ->pluck('extension')\n ->values();\n }\n\n private function resolveExtensionFromApi(string $extensionId): ?Extension\n {\n $this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);\n\n try {\n $record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));\n } catch (Throwable $e) {\n $this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);\n\n return null;\n }\n\n if ($record === null) {\n $this->logger->warning('[RingCentral] extension not found in API');\n\n return null;\n }\n\n if (! is_array($record)) {\n $this->logger->error(\n sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),\n [\n 'type' => gettype($record),\n 'record' => $record,\n ]\n );\n\n return null;\n }\n\n return Extension::createFromPayload($record);\n }\n\n /**\n *\n * @throws InvalidArgumentException\n */\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case 'Unknown':\n case 'Call connected':\n case 'Voicemail':\n case 'Accepted':\n case 'Reply':\n return Activity::STATUS_COMPLETED;\n case 'In Progress':\n return Activity::STATUS_IN_PROGRESS;\n case 'Busy':\n return Activity::STATUS_BUSY;\n case 'Received':\n return Activity::STATUS_RECEIVED;\n case 'No Answer':\n return Activity::STATUS_NO_ANSWER;\n case 'Missed':\n case 'Hang Up':\n case 'Blocked':\n case 'Suspended account':\n case 'Call Failed':\n case 'Call Failure':\n case 'Internal Error':\n case 'IP Phone Offline':\n case 'No Calling Credit':\n case 'Not Allowed':\n case 'Restricted Number':\n case 'Wrong Number':\n case 'Answered Not Accepted':\n case 'Stopped':\n case 'International Disabled':\n case 'International Restricted':\n case 'Abandoned':\n case 'Declined':\n case 'Rejected':\n case 'Fax Receipt Error':\n return Activity::STATUS_FAILED;\n default:\n throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));\n }\n }\n\n private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool\n {\n if ($existingCall === null) {\n return false;\n }\n\n $participants = $existingCall->participants()->get();\n $track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();\n\n $trackRecordingUrls = $this->getTrackRecordingUrls($track);\n $callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());\n\n $reasons = [];\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {\n $reasons[] = 'direction_changed_to_inbound';\n }\n\n if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {\n $reasons[] = 'direction_changed_to_outbound';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {\n $reasons[] = 'missing_callee_participant';\n }\n\n if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {\n $reasons[] = 'missing_caller_participant';\n }\n\n if ($trackRecordingUrls !== $callRecordingUrls) {\n $reasons[] = 'recording_urls_changed';\n }\n\n if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {\n $reasons[] = 'status_changed';\n }\n\n $needsUpdate = ! empty($reasons);\n\n if ($needsUpdate && $shouldLog) {\n $this->logger->info('[RingCentral] Call needs update', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n 'activity' => $existingCall->getUuid(),\n 'reasons' => $reasons,\n 'existingType' => $existingCall->getType(),\n 'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',\n 'existingStatus' => $existingCall->getStatus(),\n 'newStatus' => $call->getStatus(),\n ]);\n }\n\n return $needsUpdate;\n }\n\n private function getCallRecordingUrls(array $callData): array\n {\n $recordingUrls = collect($callData['legs'] ?? [])\n ->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))\n ->map(fn ($leg) => (string) $leg['recording']['contentUri'])\n ->prepend((string) ($callData['recording']['contentUri'] ?? ''))\n ->filter()\n ->unique()\n ->values()\n ->toArray();\n\n return $this->normalizeRecordingUrls($recordingUrls);\n }\n\n private function getTrackRecordingUrls(?Track $track): array\n {\n if ($track === null) {\n return [];\n }\n\n $providerContentUrl = $track->getProviderContentUrl();\n\n if ($providerContentUrl === null || $providerContentUrl === '') {\n return [];\n }\n\n try {\n $decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);\n } catch (JsonException) {\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n if (is_array($decoded)) {\n return $this->normalizeRecordingUrls($decoded);\n }\n\n return $this->normalizeRecordingUrls([$providerContentUrl]);\n }\n\n private function normalizeRecordingUrls(array $urls): array\n {\n $urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));\n $urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));\n sort($urls);\n\n return array_values(array_unique($urls));\n }\n\n public function syncExternalUsers(): Collection\n {\n $this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);\n\n return $this->client\n ->getCompanyExtensions()\n ->map([$this, 'syncUserExtension']);\n }\n\n public function syncUserExtension(Extension $remoteUser): void\n {\n $user = null;\n if ($remoteUser->getContact()->getEmailAddress() !== null) {\n $user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(\n $remoteUser->getContact()->getEmailAddress()\n );\n }\n\n // Extension IDs can move between users so update based on that.\n $this->getProvider()->providerUsers()->updateOrCreate([\n 'external_id' => $remoteUser->getId(),\n ], [\n 'user_id' => $user->id ?? null,\n ]);\n }\n\n /**\n * @throws RingCentralExtensionNotFound\n */\n private function resolveUser(Call $call): ?User\n {\n $this->logger->info('[RingCentral] Resolving user', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n $extensions = $this->resolveExtensions($call);\n\n if (empty($extensions)) {\n $this->logger->warning('[RingCentral] No extension is found', [\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUsers = $this->provider->providerUsers()\n ->whereIn('external_id', $extensions)\n ->whereNotNull('user_id')\n ->orderByRaw(\n 'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',\n $extensions\n )\n ->get();\n\n if ($providerUsers->isEmpty()) {\n $this->logger->warning('[RingCentral] No provider users found for extensions', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n $providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());\n\n if ($providerUser === null) {\n $this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [\n 'extensions' => $extensions,\n 'callId' => $call->getCallId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n return null;\n }\n\n /** @var User $user */\n $user = $providerUser->user;\n\n $this->logger->info('[RingCentral] User found', [\n 'callId' => $call->getCallId(),\n 'userId' => $user->id,\n 'teamId' => $this->team->getId(),\n ]);\n\n return $user;\n }\n\n private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'user_id' => $user->getId(),\n 'email' => $user->getEmailAddress(),\n 'name' => $user->getName(),\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant\n {\n $phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();\n\n return $activity->updateOrCreateParticipant([\n 'is_ghost' => 0,\n 'email' => $prospect->email ?? null,\n 'name' => $prospect->name ?? 'Customer',\n 'phone_number' => phone_e164(null, $phoneNumber),\n 'contact_id' => $activity->contact_id ?? null,\n 'lead_id' => $activity->lead_id ?? null,\n 'enter_time' => $activity->actual_start_time,\n 'exit_time' => $activity->actual_end_time,\n ], false);\n }\n\n /**\n * Create participant connection for organiser.\n */\n protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_WEBRTC,\n ], [\n 'direction' => $direction,\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create participant connection for prospect.\n */\n protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection\n {\n $activityRecordingState = $participant->activity->getRecordingState();\n $direction = strtolower($callData['direction']);\n\n /** @var Connection $connection */\n $connection = $participant->connections()->updateOrCreate([\n 'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.\n ], [\n 'direction' => $callData['direction'],\n 'phone' => $participant->phone_number,\n 'opened_at' => $participant->enter_time,\n 'closed_at' => $participant->exit_time,\n 'country_code' => regionCode(null, $participant->phone_number),\n 'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,\n ]);\n\n return $connection;\n }\n\n /**\n * Create and associate track to activity.\n */\n private function createTrack(array $callData, Activity $activity, Connection $connection): Track\n {\n $this->logger->info('[RingCentral] Creating track', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n ]);\n\n $recordingUrls = $this->getCallRecordingUrls($callData);\n\n try {\n $providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);\n } catch (JsonException $exception) {\n $this->logger->warning('[RingCentral] Failed to encode recording urls', [\n 'callId' => $activity->getTelephonyProviderId(),\n 'teamId' => $this->team->getId(),\n 'exception' => $exception->getMessage(),\n ]);\n\n $providerContentUrl = '[]';\n }\n\n /** @var Track $track */\n $track = $activity->tracks()->updateOrCreate([\n 'telephony_provider_id' => $activity->telephony_provider_id,\n ], [\n 'connection_id' => $connection->id,\n 'type' => Track::TYPE_AUDIO,\n 'format' => Track::FORMAT_WAV,\n 'provider_content_url' => $providerContentUrl,\n 'start_time' => $activity->actual_start_time,\n 'end_time' => $activity->actual_end_time,\n ]);\n\n $track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);\n\n return $track;\n }\n\n private function isInternalCall(Call $call): bool\n {\n $callData = $call->getPayload();\n\n $fromExtensionNumber = array_get($callData, 'from.extensionNumber');\n $toExtensionNumber = array_get($callData, 'to.extensionNumber');\n\n return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);\n }\n\n private function saveCallToCache(string $callId, string $cacheType): void\n {\n $timeout = match($cacheType) {\n self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,\n self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,\n default => 0,\n };\n\n Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));\n }\n\n private function isCallInCache(string $callId, string $cacheType): bool\n {\n return Cache::has($this->getCacheKey($callId, $cacheType));\n }\n\n private function getCacheKey(string $callId, string $cacheType): string\n {\n return 'ringcentral:import-call:' . $cacheType . ':' . $callId;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
6304111074389701896
|
-1646255984498593726
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
7
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Activity\RingCentral;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use JsonException;
use Jiminny\Contracts\Services\ImportsDataInterface;
use Jiminny\DTO\ImportCall\RingCentral\Call;
use Jiminny\DTO\ImportCall\RingCentral\Extension;
use Jiminny\Events\Activities\Dialers\CallImported;
use Jiminny\Events\Activities\Provider\SyncActivityProviderEvent;
use Jiminny\Exceptions\RingCentralExtensionNotFound;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Activity\RingCentral\DownloadTrack;
use Jiminny\Models\Activity;
use Jiminny\Models\Participant;
use Jiminny\Models\Participant\Connection;
use Jiminny\Models\Track;
use Jiminny\Models\User;
use Jiminny\Services\Activity\ActivityProviderService;
use Jiminny\Services\Activity\RingCentral\Client as RingCentralClient;
use Jiminny\Services\Crm\CachedCrmServiceDecorator;
use Sentry\Laravel\Facade as Sentry;
use Throwable;
class Service extends ActivityProviderService implements ImportsDataInterface
{
private const ENDPOINT_EXTENSION_ITEM = '/restapi/v1.0/account/~/extension/%d';
private const CRM_CALL_ID = 'rcsfl__CALL_UNIQUE_ID__c';
private const CALL_CACHE_CONCURRENCY = 'concurrency';
private const CALL_CACHE_CONCURRENCY_TIMEOUT = 10; // 10 seconds
private const CALL_CACHE_COMPLETED = 'completed';
private const CALL_CACHE_COMPLETED_TIMEOUT = 3600; // 1 hour
protected string $name = Activity::PROVIDER_RINGCENTRAL;
private bool $hasSalesforceIntegrated = false;
private CachedCrmServiceDecorator $cachedCrmServiceDecorator;
public function __construct(?array $config = [])
{
parent::__construct();
$this->client = new RingCentralClient($config);
}
public function getDisplayName(): string
{
return 'RingCentral';
}
public function getInstallationStrategy(): string
{
return self::INSTALLATION_STRATEGY_OAUTH;
}
public function onActivityProviderIntegrated(): void
{
if (! $this->hasProvider()) {
$this->setupActivityProvider();
}
$this->setSocialAccount(null);
$response = $this->client->get('/restapi/v1.0/account/~');
$this->provider->update(['provider_id' => $response['id'] ?? null]);
event(new SyncActivityProviderEvent($this->getProvider(), [
'start_date' => now()->subMonth(),
'end_date' => now(),
]));
}
public function supportedActivityChannels(): array
{
return [
Activity::TYPE_SOFTPHONE,
Activity::TYPE_SOFTPHONE_INBOUND,
];
}
/**
* @throws SocialAccountTokenInvalidException
* @throws \Jiminny\Exceptions\HttpBadRequestException
* @throws \Jiminny\Exceptions\HttpForbiddenException
* @throws \Jiminny\Exceptions\HttpNotFoundException
* @throws \Jiminny\Exceptions\QuotaExceededException
* @throws \Jiminny\Exceptions\ServiceUnavailableException
*/
public function importData(?Carbon $from = null, ?Carbon $to = null, ?User $user = null, ?string $activityId = null): ?int
{
if ($user !== null) {
$this->logger->warning('User filter is not available with this provider');
}
$this->hasSalesforceIntegrated = $this->team->crm->fields()->where([
'crm_provider_id' => self::CRM_CALL_ID,
'object_type' => 'task',
])->exists();
$this->cachedCrmServiceDecorator = resolve(CachedCrmServiceDecorator::class);
$this->cachedCrmServiceDecorator->setCrmService($this->crmService);
if ($activityId) {
$call = $this->client->getCallLog(['view' => 'Detailed'], $activityId);
if ($call === null) {
return 0;
}
$this->logger->debug('[RingCentral] ApiResponse', $call);
return (int) $this->importCall(Call::createFromPayload($call));
}
/**
* every XX:15, XX:30, and XX:45 we look back 1 hour,
* while at every round hour XX:00 we look back 6 hours
*/
$lookupCandidatePeriod = now()->subHour();
if ($from === null || $from->isAfter($lookupCandidatePeriod)) {
$from = $lookupCandidatePeriod;
}
if ($to === null) {
$to = now();
}
$syncCount = 0;
$offset = 1;
$payload = [
'perPage' => 1000,
'dateFrom' => $from->format(DATE_RFC3339_EXTENDED),
'dateTo' => $to->format(DATE_RFC3339_EXTENDED),
'type' => ['Voice'],
'view' => 'Detailed',
];
do {
$payload['page'] = $offset;
try {
$response = $this->client->getCallLog($payload);
if ($response === null) {
return $syncCount;
}
$this->logger->info(
'[RingCentral] Retrieved call log',
[
'teamId' => $this->team->id_string,
'totalCalls' => count($response['records']),
'payload' => $payload,
]
);
foreach ($response['records'] as $call) {
$ringCentralCall = Call::createFromPayload($call);
try {
$syncCount += (int) $this->importCall($ringCentralCall);
} catch (RingCentralExtensionNotFound $exception) {
$this->logger->warning('RingCentral extension not found', [
'team' => $this->team->id_string,
'extension' => $exception->getExtensionId(),
]);
Sentry::captureException($exception);
}
}
} catch (Throwable $exception) {
$this->logger->error('[RingCentral] Import has failed', [
'team' => $this->team->getSlug(),
'socialAccountId' => $this->socialAccount->getId(),
'code' => $exception->getCode(),
'exception' => $exception->getMessage(),
]);
return $syncCount;
}
$offset++;
} while (isset($response['navigation']['nextPage']));
$this->provider->update(['last_synced_at' => now()]);
return $syncCount;
}
private function importCall(Call $call): bool
{
$callId = $call->getCallId();
if (($this->isCallInCache($callId, self::CALL_CACHE_CONCURRENCY)
|| $this->isCallInCache($callId, self::CALL_CACHE_COMPLETED))
&& ! $this->debugMode
) {
$this->logger->info('[RingCentral] Call already imported', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$this->saveCallToCache($callId, self::CALL_CACHE_CONCURRENCY);
$callData = $call->getPayload();
$isOutbound = $call->isDirectionOutbound();
$playbookCategory = $lead = $account = $opportunity = $contact = $stage = null;
$fields = $task = [];
$this->logger->info('[RingCentral] Syncing call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
'callData' => $callData,
]);
if ($this->isInternalCall($call)) {
$this->logger->info('[RingCentral] Skipping internal call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! $call->isRecordingAvailable()) {
$this->logger->warning('[RingCentral] Call has no recording', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
if (! isset($callData['from'], $callData['to'])) {
$this->logger->warning('[RingCentral] Call is missing from or to data', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$user = $this->resolveUser($call);
if (! $user instanceof User) {
$this->logger->info('[RingCentral] No user found. Skipping call', [
'callId' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$existingCall = $user->activities()
->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
$callNeedsUpdate = $this->callNeedsUpdate($call, $existingCall, shouldLog: $existingCall !== null);
if ($existingCall && ! $callNeedsUpdate && ! $this->debugMode) {
$this->logger->info('[RingCentral] Call already exists', [
'call_id' => $callId,
'teamId' => $this->team->getId(),
]);
return false;
}
$phone = $isOutbound
? $call->getCalleePhoneNumber()
: $call->getCallerPhoneNumber();
$title = 'Call ' . ($isOutbound ? 'to ' : 'from ');
$playbook = $this->getPlaybook($user);
if ($this->hasSalesforceIntegrated) {
try {
$fields = $this->team->crm->fields()->where('is_indexable', 1)
->where('object_type', 'task')->get()->all();
$crmFilters = [
self::CRM_CALL_ID => '%' . $call->getSessionId() . '-%',
];
$task = $this->getTaskByCallId($call->getSessionId(), $playbook->activityField, $crmFilters, $fields);
} catch (Exception $ex) {
Sentry::captureException($ex);
}
}
if (! empty($task) && $this->hasSalesforceIntegrated) {
[$lead, $account, $opportunity, $contact, $stage] = $this->crmService->fetchProspect($task);
if ($task['Type']) {
$playbookCategory = $this->getPlaybookCategory($playbook, $task['Type']);
}
} elseif ($phone !== null) {
// Note in some cases phone might be null.
[$lead, $account, $opportunity, $contact, $stage] = $this->cachedCrmServiceDecorator->matchByPhone(
$phone,
null,
$user->getId()
);
}
if ($lead === null && $contact === null && $account === null) {
$this->logger->info(sprintf('[RingCentral] Contact does not have CRM data for call %s', $callId), [
'person' => $callData['to'],
]);
$title .= $phone;
} else {
$title .= ($lead ? $lead->name : ($contact ? $contact->name : ($account ? $account->name : '')));
}
$startedAt = $call->getActualStartTime();
$completedAt = $call->getActualEndTime();
$duration = $call->getDuration();
/** @var Activity|null $activity */
$activity = $user->activities()->withTrashed()
->where('telephony_provider_id', $callId)
->where('provider', $this->getName())
->first();
if ($activity?->trashed()) {
$this->logger->warning('[RingCentral] Existing activity is soft-deleted', ['callId' => $callId]);
return false;
}
$type = $isOutbound ? Activity::TYPE_SOFTPHONE : Activity::TYPE_SOFTPHONE_INBOUND;
$hasRecordingPrompt = false;
if (
! $isOutbound
&& ! empty($this->provider->capabilities)
&& isset($this->provider->capabilities['HasRecordingPrompt'])
) {
$hasRecordingPrompt = $this->provider->capabilities['HasRecordingPrompt'];
}
$status = $this->mapStatus($callData['result']);
$recordingReasonCode = $status === Activity::STATUS_FAILED ?
Activity::FLAG_RECORDING_REASON_EXTERNALLY_DISABLED : 0;
/** @var Activity $activity */
$activity = $user->activities()->updateOrCreate([
'telephony_provider_id' => $callId,
'provider' => $this->getName(),
], [
'type' => $type,
'playbook_category_id' => $playbookCategory->id ?? null,
'crm_configuration_id' => $this->team->crm_id,
'crm_provider_id' => $task['crmId'] ?? null, // Salesforce Activity ID
'actual_start_time' => $startedAt,
'scheduled_start_time' => $startedAt,
'actual_end_time' => $completedAt,
'scheduled_end_time' => $completedAt,
'title' => $task['subject'] ?? $title,
'lead_id' => $lead->id ?? null,
'contact_id' => $contact->id ?? null,
'account_id' => $account->id ?? null,
'opportunity_id' => $opportunity->id ?? null,
'stage_id' => $stage->id ?? null,
'status' => $status,
'duration' => $duration,
'recording_state' => Activity::RECORDING_RECORDED,
'recording_reason_code' => $recordingReasonCode,
'is_internal' => false,
'description' => $task['summary'] ?? null,
'has_recording_prompt' => $hasRecordingPrompt,
]);
$this->emitProspectAddedIfNecessary($activity, $this->getName());
$this->logger->info(
'[RingCentral] Activity finished processing',
[
'activityId' => $activity->id_string,
'callId' => $callId,
'status' => $activity->wasRecentlyCreated ? 'imported' : 'synced',
],
);
$this->logger->info('[RingCentral] Updating CRM', ['callId' => $call->getCallId()]);
$this->updateCrmFieldData($activity, $task, $fields);
$this->logger->info('[RingCentral] Creating participants', ['callId' => $call->getCallId()]);
$organiserParticipant = $this->createOrganiserParticipant($activity, $user, $call);
$organiserConnection = $this->createOrganiserParticipantConnection($callData, $organiserParticipant);
$customerParticipant = $this->createProspectParticipant($activity, $activity->prospect, $call);
$this->createProspectParticipantConnection($callData, $organiserParticipant);
$activity->update([
'from_participant_id' => $isOutbound ? $organiserParticipant->id : $customerParticipant->id,
'to_participant_id' => $isOutbound ? $customerParticipant->id : $organiserParticipant->id,
]);
$this->purgeCorruptedTracks($activity);
$track = $this->createTrack($callData, $activity, $organiserConnection);
$this->logger->info('[RingCentral] Dispatching track download event', [
'callId' => $activity->getTelephonyProviderId(),
'callNeedsUpdate' => $callNeedsUpdate,
]);
dispatch(new DownloadTrack(
trackId: $track->id_string,
socialAccountId: $this->socialAccount->id,
debugMode: $this->debugMode || $callNeedsUpdate
));
event(new CallImported($activity));
$this->saveCallToCache($callId, self::CALL_CACHE_COMPLETED);
return $activity->wasRecentlyCreated || $activity->isDirty();
}
private function resolveExtensions(Call $call): array
{
$extensions = [];
$foundExtensionIds = [];
foreach (array_reverse($call->getLegs()) as $leg) {
$extensionId = array_get($leg, 'extension.id');
if ($extensionId === null) {
continue;
}
$extensionId = (string) $extensionId;
$hasExtension = $this->getProvider()->providerUsers()
->where('external_id', $extensionId)
->exists();
if ($hasExtension) {
$extensions[] = $extensionId;
} else {
$foundExtensionIds[] = $extensionId;
}
}
if (! empty($foundExtensionIds)) {
$this->logger->info(
'[RingCentral] Some extensions were not found in db, resolving from API...',
['foundExtensionIds' => $foundExtensionIds]
);
$foundExtensionIds = array_unique($foundExtensionIds);
foreach ($foundExtensionIds as $extensionId) {
$remoteUser = $this->resolveExtensionFromApi($extensionId);
$this->logger->info('[RingCentral] fetch result', ['remote_user' => $remoteUser]);
if ($remoteUser !== null) {
$this->logger->info("[RingCentral] syncing remote user {$remoteUser->getId()}");
$this->syncUserExtension($remoteUser);
$extensions[] = (string) $remoteUser->getId();
}
}
}
$sortedExtensions = $this->sortExtensionsByStatus($extensions, $call);
// Extension assigned by RingCentral has top priority
$extensionId = $call->getExtensionId();
if (! empty($extensionId)) {
$sortedExtensions->prepend((string) $extensionId);
}
return $sortedExtensions->unique()->values()->toArray();
}
private function sortExtensionsByStatus(array $extensions, Call $call): Collection
{
$statusOrder = ['Completed', 'Call connected', 'Connected', 'Accepted'];
$reversedLegs = collect($call->getLegs())->reverse();
return collect($extensions)
->map(function ($extension) use ($reversedLegs) {
$status = $reversedLegs
->first(fn ($leg) => isset($leg['extension']['id']) && (string) $leg['extension']['id'] === $extension)
['result'] ?? null;
return ['extension' => $extension, 'result' => $status];
})
->sortBy(function ($item) use ($statusOrder) {
$index = array_search($item['result'], $statusOrder, true);
return $index !== false ? $index : PHP_INT_MAX;
})
->pluck('extension')
->values();
}
private function resolveExtensionFromApi(string $extensionId): ?Extension
{
$this->logger->info('[RingCentral] fetching extension from API', ['extensionId' => $extensionId]);
try {
$record = $this->client->get(sprintf(self::ENDPOINT_EXTENSION_ITEM, $extensionId));
} catch (Throwable $e) {
$this->logger->warning('[RingCentral] API error', ['error' => $e->getMessage()]);
return null;
}
if ($record === null) {
$this->logger->warning('[RingCentral] extension not found in API');
return null;
}
if (! is_array($record)) {
$this->logger->error(
sprintf('[RingCentral] %s:%s wrong variable type', __FILE__, __LINE__),
[
'type' => gettype($record),
'record' => $record,
]
);
return null;
}
return Extension::createFromPayload($record);
}
/**
*
* @throws InvalidArgumentException
*/
private function mapStatus(string $status): string
{
switch ($status) {
case 'Unknown':
case 'Call connected':
case 'Voicemail':
case 'Accepted':
case 'Reply':
return Activity::STATUS_COMPLETED;
case 'In Progress':
return Activity::STATUS_IN_PROGRESS;
case 'Busy':
return Activity::STATUS_BUSY;
case 'Received':
return Activity::STATUS_RECEIVED;
case 'No Answer':
return Activity::STATUS_NO_ANSWER;
case 'Missed':
case 'Hang Up':
case 'Blocked':
case 'Suspended account':
case 'Call Failed':
case 'Call Failure':
case 'Internal Error':
case 'IP Phone Offline':
case 'No Calling Credit':
case 'Not Allowed':
case 'Restricted Number':
case 'Wrong Number':
case 'Answered Not Accepted':
case 'Stopped':
case 'International Disabled':
case 'International Restricted':
case 'Abandoned':
case 'Declined':
case 'Rejected':
case 'Fax Receipt Error':
return Activity::STATUS_FAILED;
default:
throw new InvalidArgumentException(sprintf('Unknown call state: %s', $status));
}
}
private function callNeedsUpdate(Call $call, ?Activity $existingCall, bool $shouldLog = false): bool
{
if ($existingCall === null) {
return false;
}
$participants = $existingCall->participants()->get();
$track = $existingCall->tracks()->whereNotNull('telephony_provider_id')->first();
$trackRecordingUrls = $this->getTrackRecordingUrls($track);
$callRecordingUrls = $this->getCallRecordingUrls($call->getPayload());
$reasons = [];
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE && $call->isDirectionInbound()) {
$reasons[] = 'direction_changed_to_inbound';
}
if ($existingCall->getType() === Activity::TYPE_SOFTPHONE_INBOUND && $call->isDirectionOutbound()) {
$reasons[] = 'direction_changed_to_outbound';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCalleePhoneNumber()))) {
$reasons[] = 'missing_callee_participant';
}
if (! $participants->contains('phone_number', phone_e164(null, $call->getCallerPhoneNumber()))) {
$reasons[] = 'missing_caller_participant';
}
if ($trackRecordingUrls !== $callRecordingUrls) {
$reasons[] = 'recording_urls_changed';
}
if ($existingCall->getStatus() !== $this->mapStatus($call->getStatus())) {
$reasons[] = 'status_changed';
}
$needsUpdate = ! empty($reasons);
if ($needsUpdate && $shouldLog) {
$this->logger->info('[RingCentral] Call needs update', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
'activity' => $existingCall->getUuid(),
'reasons' => $reasons,
'existingType' => $existingCall->getType(),
'newDirection' => $call->isDirectionInbound() ? 'Inbound' : 'Outbound',
'existingStatus' => $existingCall->getStatus(),
'newStatus' => $call->getStatus(),
]);
}
return $needsUpdate;
}
private function getCallRecordingUrls(array $callData): array
{
$recordingUrls = collect($callData['legs'] ?? [])
->filter(fn ($leg) => ! empty($leg['recording']['contentUri']))
->map(fn ($leg) => (string) $leg['recording']['contentUri'])
->prepend((string) ($callData['recording']['contentUri'] ?? ''))
->filter()
->unique()
->values()
->toArray();
return $this->normalizeRecordingUrls($recordingUrls);
}
private function getTrackRecordingUrls(?Track $track): array
{
if ($track === null) {
return [];
}
$providerContentUrl = $track->getProviderContentUrl();
if ($providerContentUrl === null || $providerContentUrl === '') {
return [];
}
try {
$decoded = json_decode($providerContentUrl, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
if (is_array($decoded)) {
return $this->normalizeRecordingUrls($decoded);
}
return $this->normalizeRecordingUrls([$providerContentUrl]);
}
private function normalizeRecordingUrls(array $urls): array
{
$urls = array_values(array_filter($urls, static fn (mixed $url): bool => is_scalar($url) && $url !== ''));
$urls = array_values(array_filter(array_map('strval', $urls), static fn (string $url): bool => $url !== ''));
sort($urls);
return array_values(array_unique($urls));
}
public function syncExternalUsers(): Collection
{
$this->logger->info('[RingCentral] Syncing company extensions', ['team' => $this->team->getSlug()]);
return $this->client
->getCompanyExtensions()
->map([$this, 'syncUserExtension']);
}
public function syncUserExtension(Extension $remoteUser): void
{
$user = null;
if ($remoteUser->getContact()->getEmailAddress() !== null) {
$user = $this->team->findActiveNonCoachesThatSyncDialersByEmail(
$remoteUser->getContact()->getEmailAddress()
);
}
// Extension IDs can move between users so update based on that.
$this->getProvider()->providerUsers()->updateOrCreate([
'external_id' => $remoteUser->getId(),
], [
'user_id' => $user->id ?? null,
]);
}
/**
* @throws RingCentralExtensionNotFound
*/
private function resolveUser(Call $call): ?User
{
$this->logger->info('[RingCentral] Resolving user', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
$extensions = $this->resolveExtensions($call);
if (empty($extensions)) {
$this->logger->warning('[RingCentral] No extension is found', [
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUsers = $this->provider->providerUsers()
->whereIn('external_id', $extensions)
->whereNotNull('user_id')
->orderByRaw(
'FIELD(external_id, ' . implode(',', array_fill(0, count($extensions), '?')) . ')',
$extensions
)
->get();
if ($providerUsers->isEmpty()) {
$this->logger->warning('[RingCentral] No provider users found for extensions', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
$providerUser = $providerUsers->first(fn ($providerUser) => $providerUser->user->shouldSyncDialer());
if ($providerUser === null) {
$this->logger->warning('[RingCentral] Only active recorders should have meetings imported.', [
'extensions' => $extensions,
'callId' => $call->getCallId(),
'teamId' => $this->team->getId(),
]);
return null;
}
/** @var User $user */
$user = $providerUser->user;
$this->logger->info('[RingCentral] User found', [
'callId' => $call->getCallId(),
'userId' => $user->id,
'teamId' => $this->team->getId(),
]);
return $user;
}
private function createOrganiserParticipant(Activity $activity, User $user, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCallerPhoneNumber() : $call->getCalleePhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'user_id' => $user->getId(),
'email' => $user->getEmailAddress(),
'name' => $user->getName(),
'phone_number' => phone_e164(null, $phoneNumber),
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
protected function createProspectParticipant(Activity $activity, $prospect, Call $call): Participant
{
$phoneNumber = $call->isDirectionOutbound() ? $call->getCalleePhoneNumber() : $call->getCallerPhoneNumber();
return $activity->updateOrCreateParticipant([
'is_ghost' => 0,
'email' => $prospect->email ?? null,
'name' => $prospect->name ?? 'Customer',
'phone_number' => phone_e164(null, $phoneNumber),
'contact_id' => $activity->contact_id ?? null,
'lead_id' => $activity->lead_id ?? null,
'enter_time' => $activity->actual_start_time,
'exit_time' => $activity->actual_end_time,
], false);
}
/**
* Create participant connection for organiser.
*/
protected function createOrganiserParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_WEBRTC,
], [
'direction' => $direction,
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'outbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create participant connection for prospect.
*/
protected function createProspectParticipantConnection(array $callData, Participant $participant): Connection
{
$activityRecordingState = $participant->activity->getRecordingState();
$direction = strtolower($callData['direction']);
/** @var Connection $connection */
$connection = $participant->connections()->updateOrCreate([
'type' => Connection::TYPE_PSTN, // For the prospect it is always PSTN.
], [
'direction' => $callData['direction'],
'phone' => $participant->phone_number,
'opened_at' => $participant->enter_time,
'closed_at' => $participant->exit_time,
'country_code' => regionCode(null, $participant->phone_number),
'recording_state' => $direction === 'inbound' ? $activityRecordingState : Connection::RECORDING_OFF,
]);
return $connection;
}
/**
* Create and associate track to activity.
*/
private function createTrack(array $callData, Activity $activity, Connection $connection): Track
{
$this->logger->info('[RingCentral] Creating track', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
]);
$recordingUrls = $this->getCallRecordingUrls($callData);
try {
$providerContentUrl = json_encode($recordingUrls, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$this->logger->warning('[RingCentral] Failed to encode recording urls', [
'callId' => $activity->getTelephonyProviderId(),
'teamId' => $this->team->getId(),
'exception' => $exception->getMessage(),
]);
$providerContentUrl = '[]';
}
/** @var Track $track */
$track = $activity->tracks()->updateOrCreate([
'telephony_provider_id' => $activity->telephony_provider_id,
], [
'connection_id' => $connection->id,
'type' => Track::TYPE_AUDIO,
'format' => Track::FORMAT_WAV,
'provider_content_url' => $providerContentUrl,
'start_time' => $activity->actual_start_time,
'end_time' => $activity->actual_end_time,
]);
$track->wasRecentlyCreated && $track->update(['status' => Track::STATUS_PENDING]);
return $track;
}
private function isInternalCall(Call $call): bool
{
$callData = $call->getPayload();
$fromExtensionNumber = array_get($callData, 'from.extensionNumber');
$toExtensionNumber = array_get($callData, 'to.extensionNumber');
return ! empty($fromExtensionNumber) && ! empty($toExtensionNumber);
}
private function saveCallToCache(string $callId, string $cacheType): void
{
$timeout = match($cacheType) {
self::CALL_CACHE_CONCURRENCY => self::CALL_CACHE_CONCURRENCY_TIMEOUT,
self::CALL_CACHE_COMPLETED => self::CALL_CACHE_COMPLETED_TIMEOUT,
default => 0,
};
Cache::put($this->getCacheKey($callId, $cacheType), true, now()->addSeconds($timeout));
}
private function isCallInCache(string $callId, string $cacheType): bool
{
return Cache::has($this->getCacheKey($callId, $cacheType));
}
private function getCacheKey(string $callId, string $cacheType): string
{
return 'ringcentral:import-call:' . $cacheType . ':' . $callId;
}
}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55261
|
1914
|
24
|
2026-05-18T13:59:12.775052+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112752775_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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_expanded":false}]...
|
-4047408223668449518
|
-8636777727364969024
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
FirefoxFile• 0Edit→ViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:12=5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:45:39...
|
55259
|
NULL
|
NULL
|
NULL
|
|
55260
|
1915
|
12
|
2026-05-18T13:59:12.552747+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112752552_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
syncFields
New Line
Match Case
Words
Regex
Replace History
Replace...
|
[{"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":"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/>114 incoming commits<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":"Show Replace Field","depth":4,"bounds":{"left":0.14228724,"top":0.17956904,"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":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.1549202,"top":0.17877094,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"syncFields","depth":4,"bounds":{"left":0.16589096,"top":0.17877094,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"syncFields","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.21875,"top":0.17877094,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.2287234,"top":0.17877094,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.23736702,"top":0.17877094,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.24601063,"top":0.17877094,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1554061673381793970
|
-8312151337178371636
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
syncFields
New Line
Match Case
Words
Regex
Replace History
Replace
PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js?9 master kProiect vC ActivityController.ongC BaseService.pnp© SoftPhoneManager.php© ParentNode.php© RolesAndCrmOptionMismatchRule.php(C) CoreUserRequest.onpscimProvistoning.ong© CoreUser.php©) Smsmessage.pnp© TransferUserAdminRule.phpv m Servicesv @ Activity>@ Aircall>@ AmazonConnect_ Apollo> @J AvayaC BaseService› _ bloobirasu CloseCloudcallCloudtalkConnectAndSellD DemoDeskDialpadM FightBvFiahtFiveNineM GmaiD Gong^ GoogleMeet• GoToMeetingN Groovem HuhSootImportJiminnyaluct CallD Migratorh NatterhayD Office0 OrumM Outreach• C RingCentral© Client.phpC) Service.php© ThrottleHandler.php> C RingCentralVideoC7 Salesforce• Sales oftTalkdeskM TeamsTelusM Twildn TwilloflexM TwilioflexDirectM TwilioVidenM Uinloade"N VonaadM Xantim 7oom© Activity/.../Service.php x © Crm/.../Service.phpsvncrieldsx5 Ccw.*0 results1I Y :HSA3IYIY3ILAVnamespace Jiminny services AccIVicy Close:class service extends Activityproviderservice implements importsuataintertace28 6tprotected string Sname = Actavity::PROVIDER_CLOSE:private Webhook suruGenerator:31 61 >public function -_construct(?array Sconfig)l...39 0)public function getDisplayName(): stringf...}440)public function getinstallationStrategy: stringt...h49 0 )public function canBeInstalled: boolf...5L6г)public function onActivityProviderIntegrated: void{...}public function onActivityProviderEnabled: void{...}92 0 >public function onActivityProviderDisabled: void{...}107 0 >public function handleActivityProviderCallback(array $payload)(...;120 бt >public function supportedActivityChannelsO: array{...}*othrows SoczalaccountTokenInval1dzxcept1on*othrows_HttpBadrequestzxception* Othrows HttpForbiddenException* achrows HttoNotFoundExcentzon* Othrows QuotaExceededexception*@throws Servicelnavailahlesxceot.ion136 Ooublic function importData(2Carbon $from = null. 2Carbon $to = null. 2Usen Suser = null. ?strina $aif(Sactivitvid)"Snpquest =Snesnonse = Sthis->client->nenues+('GFTl Sneauest)-r' hac heon rectored Il Pollback II Confiaure (todav 14-19)= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php X<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >135136 @t›146 Gt150-151 G.1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function__construct(UserRepository $userRepository)1...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...?lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:12CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/Userl ẞ RoleAttrTest.ohn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 00-6UTF.8io 4 spaces...
|
55257
|
NULL
|
NULL
|
NULL
|
|
55259
|
1914
|
23
|
2026-05-18T13:59:12.014309+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112752014_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
syncFields
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"syncFields","depth":4,"on_screen":true,"value":"syncFields","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"0 results","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","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}]...
|
819107955851684511
|
-8240173199632769086
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
syncFields
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:115Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:45:38...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55258
|
1914
|
22
|
2026-05-18T13:59:10.397604+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112750397_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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}]...
|
-5187215517464698470
|
-8348545152461722172
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
Firefox• 0FileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform) Refinemen... 1m left)100% C8 • Mon 18 May 16:59:10|=20 5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:45:37...
|
55256
|
NULL
|
NULL
|
NULL
|
|
55257
|
1915
|
11
|
2026-05-18T13:59:09.058138+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112749058_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js?9 master kProiectC ActivityController.ongC BaseService.onp© SoftPhoneManager.phpC) ParentNode.onpC) CoreUserRequest.pnpscimProvistoning.ong© CoreUser.php© RolesAndCrmOptionMismatchRule.phpcSmsmessage.onp© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnectJ Apollo> @J AvayaC BaseService>_ bloobirasU CloSeCloudcallCloudtalkConnectAndSellD DemoDeskDialpadMFightBvEiahtFiveNineM GmaiD Gong^ GoogleMeet• GoToMeetingN GrooveM LuhSootImportJiminnyaluctCallD Migratorh NatterhayOffino0 OrumJ outreachC RingCentralRingCentralVideoC7 SalesforceSalesloft• TalkdeskTeams→D TelusaaiwillioM TwilioflexM TwiloVideoN UoloaderM VonadeMYantM 7o0mM 700mRotM7oomPhond© Activity/.../Service.php x © Crm/.../Service.phpsvncrieldsx 5 CcW .*0 results1I Y :HSA3IYIY3ILAVnamespace Jiminny services AccIVicy Close:class service extends Activityproviderservice implements importsuataintertace28protected strina Sname = Actavity::PROVIDER_CLOSE:private Webhook suruGenerator:31 61 >public function -_construct(?array Sconfig)l...39 0)public function getDisplayName: stringt...440)public function getinstallationStrategy: stringt...h49 0 )public function canBeInstalled: boolf...SL6T)public function onActivityProviderIntegrated: void{...}public function onActivityProviderEnabled: void{...}92 0 >public function onActivityProviderDisabled: void{...}107 Gipublic function handleActivityProviderCallback(array $payload)(...;120 бt >public function supportedActivityChannelsO: array{...}*othrows SoczalaccountTokenInval1dzxcept1on*othrows_HttpBadrequestzxception* Othrows HttpForbiddenException* achrows HttoNotFoundExcention* Othrows QuotaExceededexception*@throws Servicelnavailahlesxcent.ion136 Gtoublic function importData(2Carbon $from = null. 2Carbon $to = null. 2Usen Suser = null. ?strina $aif(Sactivitvid)"Snesnonse = Sthis->client->nenues+('GFTl Sneauest)-= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php X<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >135136 @t›146 Gt150-151 G.1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function__construct(UserRepository $userRepository)1...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...?lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:08CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/Userl ẞ RoleAttrTest.ohn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 00-6UTF.8io 4 spaces...
|
NULL
|
-2532989593753039035
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIewINavicarecodeLaravelKeractorFV faVsco. PhostormVIewINavicarecodeLaravelKeractorFV faVsco.js?9 master kProiectC ActivityController.ongC BaseService.onp© SoftPhoneManager.phpC) ParentNode.onpC) CoreUserRequest.pnpscimProvistoning.ong© CoreUser.php© RolesAndCrmOptionMismatchRule.phpcSmsmessage.onp© TransferUserAdminRule.phpv D Servicesv @ Activity>@ Aircall>@ AmazonConnectJ Apollo> @J AvayaC BaseService>_ bloobirasU CloSeCloudcallCloudtalkConnectAndSellD DemoDeskDialpadMFightBvEiahtFiveNineM GmaiD Gong^ GoogleMeet• GoToMeetingN GrooveM LuhSootImportJiminnyaluctCallD Migratorh NatterhayOffino0 OrumJ outreachC RingCentralRingCentralVideoC7 SalesforceSalesloft• TalkdeskTeams→D TelusaaiwillioM TwilioflexM TwiloVideoN UoloaderM VonadeMYantM 7o0mM 700mRotM7oomPhond© Activity/.../Service.php x © Crm/.../Service.phpsvncrieldsx 5 CcW .*0 results1I Y :HSA3IYIY3ILAVnamespace Jiminny services AccIVicy Close:class service extends Activityproviderservice implements importsuataintertace28protected strina Sname = Actavity::PROVIDER_CLOSE:private Webhook suruGenerator:31 61 >public function -_construct(?array Sconfig)l...39 0)public function getDisplayName: stringt...440)public function getinstallationStrategy: stringt...h49 0 )public function canBeInstalled: boolf...SL6T)public function onActivityProviderIntegrated: void{...}public function onActivityProviderEnabled: void{...}92 0 >public function onActivityProviderDisabled: void{...}107 Gipublic function handleActivityProviderCallback(array $payload)(...;120 бt >public function supportedActivityChannelsO: array{...}*othrows SoczalaccountTokenInval1dzxcept1on*othrows_HttpBadrequestzxception* Othrows HttpForbiddenException* achrows HttoNotFoundExcention* Othrows QuotaExceededexception*@throws Servicelnavailahlesxcent.ion136 Gtoublic function importData(2Carbon $from = null. 2Carbon $to = null. 2Usen Suser = null. ?strina $aif(Sactivitvid)"Snesnonse = Sthis->client->nenues+('GFTl Sneauest)-= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php X<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >135136 @t›146 Gt150-151 G.1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function__construct(UserRepository $userRepository)1...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...?lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 1m left100% 2• Mon 18 May 16:59:08CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/Userl ẞ RoleAttrTest.ohn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 00-6UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55256
|
1914
|
21
|
2026-05-18T13:59:09.055299+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112749055_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles→ToolsW FirefoxFileEditViewHistoryBookmarksProfiles→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:08)57|A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:45:35...
|
NULL
|
-4515922716473065891
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfiles→ToolsW FirefoxFileEditViewHistoryBookmarksProfiles→ToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:08)57|A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:45:35...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55255
|
1914
|
20
|
2026-05-18T13:59:07.527647+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112747527_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
syncFields
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"syncFields","depth":4,"on_screen":true,"value":"syncFields","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"0 results","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","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}]...
|
819107955851684511
|
-8240173199632769086
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
syncFields
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
0 results
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Sync Changes
Hide This Notification
Code changed:
Hide
FirefoxFile• 0EditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left)100% C8 • Mon 18 May 16:59:07=5Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement ®1:45:34...
|
55254
|
NULL
|
NULL
|
NULL
|
|
55254
|
1914
|
19
|
2026-05-18T13:59:04.526929+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112744526_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
syncFields
New Line
Match Case
Words
Regex
Replace History
Replace...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"syncFields","depth":4,"on_screen":true,"value":"syncFields","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1554061673381793970
|
-8312151337178371636
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
syncFields
New Line
Match Case
Words
Regex
Replace History
Replace
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:045Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:45:31...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55253
|
1914
|
18
|
2026-05-18T13:59:01.518684+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112741518_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
525510904961345352
|
-8348254863939620922
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Show Replace Field
Search History
FirefoxFileEditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 1 m left100% C8 • Mon 18 May 16:59:015Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:59 PM | [Platform] Refinement •1:45:28...
|
55251
|
NULL
|
NULL
|
NULL
|
|
55252
|
1915
|
10
|
2026-05-18T13:59:00.489791+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112740489_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewINavicareCodeLaravelKeractorFV faVsco. PhostormVIewINavicareCodeLaravelKeractorFV faVsco.js?9 master k. vProiect vC ActivityController.ongC BaseService.onp© SoftPhoneManager.phpDAircalllAmazonConnectApolloAvavaBaseServiceBloobirdsv W Close© Client.phpC Service.pnp> M CloudCall1 CloudTalkM ConnectAndSellTm DemoDeckTM DialnadEightByEightC FiveNineC7 GmaiC Gong07 GoogleMeetC GoToMeeting_ Groovea HubspotlimoorC Jiminny> DJustcalli0 MiaratorNatterboxM office) OrumM OutreachRinaCentraliRinaCentralVideoIm SalestorceSalesloftTalkdeskM ToameD TelusM TwilioD TwilioFlexM TwilioSleyDiroctM TwilioVidedC UploaderCa VonageDXant0 ZoomMzaampotZoomPhone(C) CoreUserRequest.onpc ScimProvisioning.ong© CoreUser.php© Activity/.../Service.php x © Crm/.../Service.phpsvncrleldsx5 Ccw.*0 results1I Y :s:onpASA3IYIV3ILAVnamespace Jiminny services AccIVicy Close:class service extends Activityproviderservice implements importsuataintertace28 0protected string Sname = Actavity::PROVIDER_CLOSE:private Webhook suruGenerator:31 61 >public function -_construct(?array Sconfig)l...39 0)public function getDisplayName: stringt...440)public function getinstallationStrategy: stringt...h49 0 )public function canBeInstalled: boolf...5L6г)public function onActivityProviderIntegrated: void{...}public function onActivityProviderEnabled: void{...=l92 0 >public function onActivityProviderDisabled: void{...}107 0 >public function handleActivityProviderCallback(array $payload)(...;120 бt >public function supportedActivityChannelsO: array{...}*othrows SoczalaccountTokenInval1dzxcept1on*othrows_HttpBadrequestzxception* Othrows HttpForbiddenException* achrows HttoNotFoundExcention* @throws Quotaexceededexception*@throws Servicelnavailahlesxcent.ion136 Gtoublic function importData(2Carbon $from = null. 2Carbon $to = null. 2Usen Suser = null. ?strina $aC) ActivitvcrmFieldsResolver.ohoC) ActivityLoaService.ohoC-ActivitvProviderClient.oho138113914Alif(Sactivitvid)"Snpquest =C) ActivitvProviderReaistrv.ohoSnesnonse = Sthis->client->nenues+('GFTl Sneauest)-CActivitvProviderService.ono= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php X<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >135136 @t›146 Gt150-151 G.1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function__construct(UserRepository $userRepository)1...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...?lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 2m left100% 1• Mon 18 May 16:59:00U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 00-6UTF.8io 4 spaces...
|
NULL
|
2805537806076197378
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIewINavicareCodeLaravelKeractorFV faVsco. PhostormVIewINavicareCodeLaravelKeractorFV faVsco.js?9 master k. vProiect vC ActivityController.ongC BaseService.onp© SoftPhoneManager.phpDAircalllAmazonConnectApolloAvavaBaseServiceBloobirdsv W Close© Client.phpC Service.pnp> M CloudCall1 CloudTalkM ConnectAndSellTm DemoDeckTM DialnadEightByEightC FiveNineC7 GmaiC Gong07 GoogleMeetC GoToMeeting_ Groovea HubspotlimoorC Jiminny> DJustcalli0 MiaratorNatterboxM office) OrumM OutreachRinaCentraliRinaCentralVideoIm SalestorceSalesloftTalkdeskM ToameD TelusM TwilioD TwilioFlexM TwilioSleyDiroctM TwilioVidedC UploaderCa VonageDXant0 ZoomMzaampotZoomPhone(C) CoreUserRequest.onpc ScimProvisioning.ong© CoreUser.php© Activity/.../Service.php x © Crm/.../Service.phpsvncrleldsx5 Ccw.*0 results1I Y :s:onpASA3IYIV3ILAVnamespace Jiminny services AccIVicy Close:class service extends Activityproviderservice implements importsuataintertace28 0protected string Sname = Actavity::PROVIDER_CLOSE:private Webhook suruGenerator:31 61 >public function -_construct(?array Sconfig)l...39 0)public function getDisplayName: stringt...440)public function getinstallationStrategy: stringt...h49 0 )public function canBeInstalled: boolf...5L6г)public function onActivityProviderIntegrated: void{...}public function onActivityProviderEnabled: void{...=l92 0 >public function onActivityProviderDisabled: void{...}107 0 >public function handleActivityProviderCallback(array $payload)(...;120 бt >public function supportedActivityChannelsO: array{...}*othrows SoczalaccountTokenInval1dzxcept1on*othrows_HttpBadrequestzxception* Othrows HttpForbiddenException* achrows HttoNotFoundExcention* @throws Quotaexceededexception*@throws Servicelnavailahlesxcent.ion136 Gtoublic function importData(2Carbon $from = null. 2Carbon $to = null. 2Usen Suser = null. ?strina $aC) ActivitvcrmFieldsResolver.ohoC) ActivityLoaService.ohoC-ActivitvProviderClient.oho138113914Alif(Sactivitvid)"Snpquest =C) ActivitvProviderReaistrv.ohoSnesnonse = Sthis->client->nenues+('GFTl Sneauest)-CActivitvProviderService.ono= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php X<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >135136 @t›146 Gt150-151 G.1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository suserRenository:public function__construct(UserRepository $userRepository)1...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue: array{...}public function aetValidationRules(2strina Sorefix = null): arrav{...?lpublic function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 2m left100% 1• Mon 18 May 16:59:00U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSCIM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3app/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 00-6UTF.8io 4 spaces...
|
55250
|
NULL
|
NULL
|
NULL
|
|
55251
|
1914
|
17
|
2026-05-18T13:58:58.510765+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112738510_m1.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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}]...
|
5222591262487654398
|
-8636637129462003327
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen Project: faVsco.js, menu
master, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
FirefoxFile• 0EditViewHistory→BookmarksProfilesTools|WindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% C8 • Mon 18 May 16:58:58)=A05Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:45:25...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55250
|
1915
|
9
|
2026-05-18T13:58:57.142067+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112737142_m2.jpg...
|
PhpStorm
|
faVsco.js – Activity/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormFV faVsco.js~VIewg9 master kINavigareCodeL PhostormFV faVsco.js~VIewg9 master kINavigareCodeLaravelKeractorProletey© FieldDefinitions.php© rielavalueconverter.ongg service.onp) standararielameradata.onpDCopper_ crmobiectsDecorateActivity,— DummyW) Helpers>D HubspotC IntegrationApp> D AccessorsDApi• contioODTO• M FiltersJobsProspectSearchStrategyO ServiceTraits© DataClient.php© DecorateActivity.php© LocalSearch.php© LocalSearchInterface.php©RemoteSearch.php© Service.php~ D Listeners© ConvertLeadActivities.php© PurgeLookupCache.php› D Metadata_ Miaration> D Pipedrivev D Salesforce>D Fields• OpportunityMatcherOpoortunitvsvncstrateav_ ProspectSearchStrateav• → ServiceTraitsC) Client.ohoC) [EMAIL]) Sieldbefinitions.onnC) Pavload?uilder.ohnC) Profile.nhn© QueryBullder.phpC) @uervHandler.nhnl©Querylterator.php© QueryResults.php© Service.php© SyncBatchRedisService.phpTraits© BaseClient.php© BaseService.php© CoreUserRequest.php© SoftPhoneManager.phpscimProvistoning.ong© CoreUser.php© Activity/../.Service.php x) © Crm/../Service.phpsvnc-ieldsx = co w .*0 resultsT.T:HSA3IYIY3ILAVnamespace Jiminny services AccIvicy closeclass service extends Activityproviderservice implements importsuataintertace28 0protected string Sname = Actavity::PROVIDER_CLOSE:private Webhook suruGenerator:31 đ >public function -_construct(?array Sconfig)l...39 đ >public function getDisplayName(): stringf...}44 Ф >public function getInstallationStrategy(): stringf...}49 đt >public function canBeInstalled(): boolf...54 @>public function onActivityProviderIntegrated(): voidf…..}87 @›public function onActivityProviderEnabled(): voidf..92@ >public function onActivityProviderDisabled(): void{..}107 @>public function handLeActivityProviderCallback(array $payload){.,120 đt >public function supportedActivityChannels(): arrayf...,*othrows SoczalaccountTokenInval1dzxcept1on*othrows_HttpBadrequestzxception* @throws HttpForbiddenException* achrows HttoNotFoundExcentzon* @throws QuotaExceededException*@throws Servicelnavailahlesxcent.ion136 Ooublic function importData(2Carbon $from = null. 2Carbon $to = null. 2Usen Suser = null. ?strina $aif(Sactivitvid) "Snpquest = lactivitvicallyt Cactivitvid. 1/1.14Al$response = Sthis->client->request('GET', Srequest);Workcnace accociated with hranch 'macter' hac heon rectored /l Pollback II Confiaure (todav 14-19)=custom.logA console [STAGING]<?phpE laravel.log4 SF [jiminny@localhost)© CoachingFeedbackCoachUserin.phpxA HS_Jocal [jiminny@localhost]A console [PROD]# console [euydeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 đ >34 đt >45 đ >135136 € >146 @ >150-151 G.1usadeorivate const int No GROUP 10 = 9993 usagesprivate UserRepository $userRepository;public function __construct(UserRepository $userRepository){.}public function shouldApplyQueries(): boolf...}public function getQueries(): FilterDefinitionQueryCollectionf...+public function toArray(): arrayf..,private function getOptions(): arrayf..,public function getValue(): arrayf...}private function getDefaultValue(): arrayf…..,public function getValidationRules(?string $prefix = null): arrayf…..,public function getSortOrder(): intf...}public function shouldßBeIncluded(Team $team): boolf...}[Platform] Refinemen... 2m left100% 18• Mon 18 May 16:58:56U AskJiminnyReportActivityServiceTest~CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..php on LineWARN Metadata found iin doc-comment for methodw9 / 10 tasks done :• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META_ROLES and USER_PATCH_ATTR_ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•Listener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintegration.8 Hes wth chingon rapp/Component/SClM/ Constants.php +3app/Component/SCIM/ ScimProvisioning.php +85-15nse/D CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ucer/M PoleAttr.nho 417View allites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent allAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 290-1UTF.8f 4 spaces...
|
NULL
|
-8634923014149873936
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormFV faVsco.js~VIewg9 master kINavigareCodeL PhostormFV faVsco.js~VIewg9 master kINavigareCodeLaravelKeractorProletey© FieldDefinitions.php© rielavalueconverter.ongg service.onp) standararielameradata.onpDCopper_ crmobiectsDecorateActivity,— DummyW) Helpers>D HubspotC IntegrationApp> D AccessorsDApi• contioODTO• M FiltersJobsProspectSearchStrategyO ServiceTraits© DataClient.php© DecorateActivity.php© LocalSearch.php© LocalSearchInterface.php©RemoteSearch.php© Service.php~ D Listeners© ConvertLeadActivities.php© PurgeLookupCache.php› D Metadata_ Miaration> D Pipedrivev D Salesforce>D Fields• OpportunityMatcherOpoortunitvsvncstrateav_ ProspectSearchStrateav• → ServiceTraitsC) Client.ohoC) [EMAIL]) Sieldbefinitions.onnC) Pavload?uilder.ohnC) Profile.nhn© QueryBullder.phpC) @uervHandler.nhnl©Querylterator.php© QueryResults.php© Service.php© SyncBatchRedisService.phpTraits© BaseClient.php© BaseService.php© CoreUserRequest.php© SoftPhoneManager.phpscimProvistoning.ong© CoreUser.php© Activity/../.Service.php x) © Crm/../Service.phpsvnc-ieldsx = co w .*0 resultsT.T:HSA3IYIY3ILAVnamespace Jiminny services AccIvicy closeclass service extends Activityproviderservice implements importsuataintertace28 0protected string Sname = Actavity::PROVIDER_CLOSE:private Webhook suruGenerator:31 đ >public function -_construct(?array Sconfig)l...39 đ >public function getDisplayName(): stringf...}44 Ф >public function getInstallationStrategy(): stringf...}49 đt >public function canBeInstalled(): boolf...54 @>public function onActivityProviderIntegrated(): voidf…..}87 @›public function onActivityProviderEnabled(): voidf..92@ >public function onActivityProviderDisabled(): void{..}107 @>public function handLeActivityProviderCallback(array $payload){.,120 đt >public function supportedActivityChannels(): arrayf...,*othrows SoczalaccountTokenInval1dzxcept1on*othrows_HttpBadrequestzxception* @throws HttpForbiddenException* achrows HttoNotFoundExcentzon* @throws QuotaExceededException*@throws Servicelnavailahlesxcent.ion136 Ooublic function importData(2Carbon $from = null. 2Carbon $to = null. 2Usen Suser = null. ?strina $aif(Sactivitvid) "Snpquest = lactivitvicallyt Cactivitvid. 1/1.14Al$response = Sthis->client->request('GET', Srequest);Workcnace accociated with hranch 'macter' hac heon rectored /l Pollback II Confiaure (todav 14-19)=custom.logA console [STAGING]<?phpE laravel.log4 SF [jiminny@localhost)© CoachingFeedbackCoachUserin.phpxA HS_Jocal [jiminny@localhost]A console [PROD]# console [euydeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 đ >34 đt >45 đ >135136 € >146 @ >150-151 G.1usadeorivate const int No GROUP 10 = 9993 usagesprivate UserRepository $userRepository;public function __construct(UserRepository $userRepository){.}public function shouldApplyQueries(): boolf...}public function getQueries(): FilterDefinitionQueryCollectionf...+public function toArray(): arrayf..,private function getOptions(): arrayf..,public function getValue(): arrayf...}private function getDefaultValue(): arrayf…..,public function getValidationRules(?string $prefix = null): arrayf…..,public function getSortOrder(): intf...}public function shouldßBeIncluded(Team $team): boolf...}[Platform] Refinemen... 2m left100% 18• Mon 18 May 16:58:56U AskJiminnyReportActivityServiceTest~CascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..php on LineWARN Metadata found iin doc-comment for methodw9 / 10 tasks done :• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META_ROLES and USER_PATCH_ATTR_ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•Listener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintegration.8 Hes wth chingon rapp/Component/SClM/ Constants.php +3app/Component/SCIM/ ScimProvisioning.php +85-15nse/D CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ucer/M PoleAttr.nho 417View allites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent allAsk anvthina (&4-L)« Code SWF-1.6WN Windsurf Toams 290-1UTF.8f 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55249
|
1914
|
16
|
2026-05-18T13:58:55.455180+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112735455_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Close/Ser
Crm/…/Service.php .../app/Services/Crm/Close/Service.php, class
Activity/…/Service.php .../app/Services/Activity/Close/Service.php, class
ServiceTest.php .../tests/Unit/Services/Crm/Close/ServiceTest.php, class
ServiceTest.php .../tests/Unit/Services/Activity/Close/ServiceTest.php, class
Service.php.html build/coverage/Services/Activity/Close
Service.php.html build/coverage/Services/Crm/Close
Crm/…/Service.php .../app/Services/Crm/Close/Service.php, class
Activity/…/Service.php .../app/Services/Activity/Close/Service.php, class
ServiceTest.php .../tests/Unit/Services/Crm/Close/ServiceTest.php, class
ServiceTest.php .../tests/Unit/Services/Activity/Close/ServiceTest.php, class...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Close/Ser","depth":1,"on_screen":true,"value":"Close/Ser","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Crm/…/Service.php .../app/Services/Crm/Close/Service.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Activity/…/Service.php .../app/Services/Activity/Close/Service.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ServiceTest.php .../tests/Unit/Services/Crm/Close/ServiceTest.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ServiceTest.php .../tests/Unit/Services/Activity/Close/ServiceTest.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php.html build/coverage/Services/Activity/Close","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Service.php.html build/coverage/Services/Crm/Close","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Crm/…/Service.php .../app/Services/Crm/Close/Service.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Activity/…/Service.php .../app/Services/Activity/Close/Service.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ServiceTest.php .../tests/Unit/Services/Crm/Close/ServiceTest.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ServiceTest.php .../tests/Unit/Services/Activity/Close/ServiceTest.php, class","depth":4,"on_screen":true,"role_description":"text"}]...
|
4811254739220737016
|
3783804354481332002
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Close/Ser
Crm/…/Service.php .../app/Services/Crm/Close/Service.php, class
Activity/…/Service.php .../app/Services/Activity/Close/Service.php, class
ServiceTest.php .../tests/Unit/Services/Crm/Close/ServiceTest.php, class
ServiceTest.php .../tests/Unit/Services/Activity/Close/ServiceTest.php, class
Service.php.html build/coverage/Services/Activity/Close
Service.php.html build/coverage/Services/Crm/Close
Crm/…/Service.php .../app/Services/Crm/Close/Service.php, class
Activity/…/Service.php .../app/Services/Activity/Close/Service.php, class
ServiceTest.php .../tests/Unit/Services/Crm/Close/ServiceTest.php, class
ServiceTest.php .../tests/Unit/Services/Activity/Close/ServiceTest.php, class...
|
55248
|
NULL
|
NULL
|
NULL
|
|
55248
|
1914
|
15
|
2026-05-18T13:58:52.435395+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112732435_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Close/
Closed.php .../app/Events/Activities/Connections/Closed.php, class
Closed.php .../app/Listeners/Activities/Connections/Closed.php, class
ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class
Closed.php.html build/coverage/Events/Activities/Connections
Closed.php.html build/coverage/Listeners/Activities/Connections
CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class
CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes
CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class
ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Close/","depth":1,"on_screen":true,"value":"Close/","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Closed.php .../app/Events/Activities/Connections/Closed.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php .../app/Listeners/Activities/Connections/Closed.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php.html build/coverage/Events/Activities/Connections","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php.html build/coverage/Listeners/Activities/Connections","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class","depth":2,"on_screen":true,"role_description":"text"}]...
|
477553282710369401
|
-8754183929645391219
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Close/
Closed.php .../app/Events/Activities/Connections/Closed.php, class
Closed.php .../app/Listeners/Activities/Connections/Closed.php, class
ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class
Closed.php.html build/coverage/Events/Activities/Connections
Closed.php.html build/coverage/Listeners/Activities/Connections
CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class
CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes
CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class
ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55247
|
1915
|
8
|
2026-05-18T13:58:48.066117+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112728066_m2.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Close
Closed.php .../app/Events/Activities/Connections/Closed.php, class
Closed.php .../app/Listeners/Activities/Connections/Closed.php, class
ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class
Closed.php.html build/coverage/Events/Activities/Connections
Closed.php.html build/coverage/Listeners/Activities/Connections
CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class
CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes
CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class
ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class
ClosedDealStagesService.php .../app/Services/Crm/Hubspot/ClosedDealStagesService.php, class
ClosedDealStagesServiceTest.php .../tests/Unit/Services/Crm/Hubspot/ClosedDealStagesServiceTest.php, class
ClosedDealsFilter.php.html build/coverage/Component/ActivitySearch/FilterDefinition
CloseDefaultStrategy.php.html build/coverage/Services/Crm/Close/ProspectSearchStrategy
… more
Closed.php .../app/Events/Activities/Connections/Closed.php, class
Closed.php .../app/Listeners/Activities/Connections/Closed.php, class
ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class
Closed.php.html build/coverage/Events/Activities/Connections
Closed.php.html build/coverage/Listeners/Activities/Connections
CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class
CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes
CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class
ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class
ClosedDealStagesService.php .../app/Services/Crm/Hubspot/ClosedDealStagesService.php, class
ClosedDealStagesServiceTest.php .../tests/Unit/Services/Crm/Hubspot/ClosedDealStagesServiceTest.php, class
ClosedDealsFilter.php.html build/coverage/Component/ActivitySearch/FilterDefinition
CloseDefaultStrategy.php.html build/coverage/Services/Crm/Close/ProspectSearchStrategy
… more
Events/Activities/Connections/Closed.php
Open In Right Split...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"bounds":{"left":0.6968085,"top":0.24181964,"width":0.03557181,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"bounds":{"left":0.73238033,"top":0.24181964,"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":"Filter","depth":2,"bounds":{"left":0.74102396,"top":0.24181964,"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 in Find Tool Window","depth":2,"bounds":{"left":0.7496675,"top":0.24181964,"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":"AXTextField","text":"Close","depth":1,"bounds":{"left":0.50232714,"top":0.27214685,"width":0.25598404,"height":0.023144454},"on_screen":true,"value":"Close","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Closed.php .../app/Events/Activities/Connections/Closed.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.30407023,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php .../app/Listeners/Activities/Connections/Closed.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.3216281,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class","depth":2,"bounds":{"left":0.4993351,"top":0.33918595,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php.html build/coverage/Events/Activities/Connections","depth":2,"bounds":{"left":0.4993351,"top":0.3567438,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php.html build/coverage/Listeners/Activities/Connections","depth":2,"bounds":{"left":0.4993351,"top":0.37430167,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.39185953,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.4094174,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks","depth":2,"bounds":{"left":0.4993351,"top":0.42697525,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes","depth":2,"bounds":{"left":0.4993351,"top":0.4445331,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.46209097,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class","depth":2,"bounds":{"left":0.4993351,"top":0.47964883,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealStagesService.php .../app/Services/Crm/Hubspot/ClosedDealStagesService.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.49720672,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealStagesServiceTest.php .../tests/Unit/Services/Crm/Hubspot/ClosedDealStagesServiceTest.php, class","depth":2,"bounds":{"left":0.4993351,"top":0.51476455,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealsFilter.php.html build/coverage/Component/ActivitySearch/FilterDefinition","depth":2,"bounds":{"left":0.4993351,"top":0.5323224,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDefaultStrategy.php.html build/coverage/Services/Crm/Close/ProspectSearchStrategy","depth":2,"bounds":{"left":0.4993351,"top":0.54988027,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":2,"bounds":{"left":0.4993351,"top":0.5674381,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php .../app/Events/Activities/Connections/Closed.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.30407023,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php .../app/Listeners/Activities/Connections/Closed.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.3216281,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class","depth":4,"bounds":{"left":0.4993351,"top":0.33918595,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php.html build/coverage/Events/Activities/Connections","depth":4,"bounds":{"left":0.4993351,"top":0.3567438,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Closed.php.html build/coverage/Listeners/Activities/Connections","depth":4,"bounds":{"left":0.4993351,"top":0.37430167,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.39185953,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.4094174,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks","depth":4,"bounds":{"left":0.4993351,"top":0.42697525,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes","depth":4,"bounds":{"left":0.4993351,"top":0.4445331,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.46209097,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class","depth":4,"bounds":{"left":0.4993351,"top":0.47964883,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealStagesService.php .../app/Services/Crm/Hubspot/ClosedDealStagesService.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.49720672,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealStagesServiceTest.php .../tests/Unit/Services/Crm/Hubspot/ClosedDealStagesServiceTest.php, class","depth":4,"bounds":{"left":0.4993351,"top":0.51476455,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"ClosedDealsFilter.php.html build/coverage/Component/ActivitySearch/FilterDefinition","depth":4,"bounds":{"left":0.4993351,"top":0.5323224,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"CloseDefaultStrategy.php.html build/coverage/Services/Crm/Close/ProspectSearchStrategy","depth":4,"bounds":{"left":0.4993351,"top":0.54988027,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":4,"bounds":{"left":0.4993351,"top":0.5674381,"width":0.26196808,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"Events/Activities/Connections/Closed.php","depth":1,"bounds":{"left":0.50598407,"top":0.75259376,"width":0.07912234,"height":0.013567438},"on_screen":true,"help_text":"Events/Activities/Connections/Closed.php","role_description":"text"},{"role":"AXLink","text":"Open In Right Split","depth":1,"bounds":{"left":0.71476066,"top":0.75259376,"width":0.03856383,"height":0.013567438},"on_screen":true,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3958952143131987654
|
-4139212003874064757
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
Close
Closed.php .../app/Events/Activities/Connections/Closed.php, class
Closed.php .../app/Listeners/Activities/Connections/Closed.php, class
ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class
Closed.php.html build/coverage/Events/Activities/Connections
Closed.php.html build/coverage/Listeners/Activities/Connections
CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class
CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes
CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class
ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class
ClosedDealStagesService.php .../app/Services/Crm/Hubspot/ClosedDealStagesService.php, class
ClosedDealStagesServiceTest.php .../tests/Unit/Services/Crm/Hubspot/ClosedDealStagesServiceTest.php, class
ClosedDealsFilter.php.html build/coverage/Component/ActivitySearch/FilterDefinition
CloseDefaultStrategy.php.html build/coverage/Services/Crm/Close/ProspectSearchStrategy
… more
Closed.php .../app/Events/Activities/Connections/Closed.php, class
Closed.php .../app/Listeners/Activities/Connections/Closed.php, class
ClosedDealsFilter.php .../app/Component/ActivitySearch/FilterDefinition/ClosedDealsFilter.php, final class
Closed.php.html build/coverage/Events/Activities/Connections
Closed.php.html build/coverage/Listeners/Activities/Connections
CloseDatePassedDealRisk.php .../app/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRisk.php, class
CloseDatePassedDealRiskTest.php .../tests/Unit/Component/DealRisks/DealRiskTypes/CloseDatePassedDealRiskTest.php, class
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks
CloseDatePassedDealRisk.php.html build/coverage/Component/DealRisks/DealRiskTypes
CloseDefaultStrategy.php .../app/Services/Crm/Close/ProspectSearchStrategy/CloseDefaultStrategy.php, class
ClosedDealsFilterTest.php .../tests/Unit/Component/ActivitySearch/FilterDefinition/ClosedDealsFilterTest.php, final class
ClosedDealStagesService.php .../app/Services/Crm/Hubspot/ClosedDealStagesService.php, class
ClosedDealStagesServiceTest.php .../tests/Unit/Services/Crm/Hubspot/ClosedDealStagesServiceTest.php, class
ClosedDealsFilter.php.html build/coverage/Component/ActivitySearch/FilterDefinition
CloseDefaultStrategy.php.html build/coverage/Services/Crm/Close/ProspectSearchStrategy
… more
Events/Activities/Connections/Closed.php
Open In Right Split...
|
55244
|
NULL
|
NULL
|
NULL
|
|
55246
|
1914
|
14
|
2026-05-18T13:58:46.370558+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112726370_m1.jpg...
|
PhpStorm
|
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
fd
FieldData.php .../app/Models/Crm/FieldData.php, class
FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class
FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class
FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class
FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class
FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class
FieldData.php.html build/coverage/Models/Crm
… more
FieldData.php .../app/Models/Crm/FieldData.php, class
FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class
FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class
FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class
FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class
FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class
FieldData.php.html build/coverage/Models/Crm
… more...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project Files","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Preview","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Find Tool Window","depth":2,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"fd","depth":1,"on_screen":true,"value":"fd","role_description":"text field","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"FieldData.php .../app/Models/Crm/FieldData.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldData.php.html build/coverage/Models/Crm","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":2,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldData.php .../app/Models/Crm/FieldData.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"FieldData.php.html build/coverage/Models/Crm","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"… more","depth":4,"on_screen":true,"role_description":"text"}]...
|
6365949302608459994
|
-855135564018024921
|
visual_change
|
accessibility
|
NULL
|
Project Files
Preview
Filter
Open in Find Tool Win Project Files
Preview
Filter
Open in Find Tool Window
fd
FieldData.php .../app/Models/Crm/FieldData.php, class
FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class
FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class
FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class
FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class
FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class
FieldData.php.html build/coverage/Models/Crm
… more
FieldData.php .../app/Models/Crm/FieldData.php, class
FieldDefinitions.php .../app/Services/Crm/Close/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Bullhorn/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Copper/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Hubspot/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Pipedrive/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/Salesforce/FieldDefinitions.php, class
FieldDefinitions.php .../app/Services/Crm/IntegrationApp/DTO/Utils/FieldDefinitions.php, class
FieldDefinitionBuilderTest.php .../tests/Unit/Component/AiAutomation/FieldDefinitionBuilderTest.php, class
FieldDataRepository.php .../app/Repositories/Crm/FieldDataRepository.php, class
FieldDefinitionBuilder.php .../app/Component/AiAutomation/FieldDefinitionBuilder.php, class
FieldDefinitionsTest.php .../tests/Unit/Integrations/Crm/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Bullhorn/FieldDefinitionsTest.php, class
FieldDefinitionsTest.php .../tests/Unit/Services/Crm/Close/FieldDefinitionsTest.php, class
FieldData.php.html build/coverage/Models/Crm
… more...
|
55245
|
NULL
|
NULL
|
NULL
|
|
55245
|
1914
|
13
|
2026-05-18T13:58:44.459488+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112724459_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% <78 • Mon 18 May 16:58:44A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:45:11...
|
NULL
|
-3862707259578014089
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% <78 • Mon 18 May 16:58:44A05Galya DimitrovaNikolay Yankov*Nikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:45:11...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55244
|
1915
|
7
|
2026-05-18T13:58:42.531812+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112722531_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIeWINavicarecodeLaravelKeractorWindowFV f PhostormVIeWINavicarecodeLaravelKeractorWindowFV faVsco.js°9 master kProiectt..:C) StandardFieldMetadata,oho> _ CoopenCrmObiects> → DecorateActivity→ Dummy> → Helpersv MHubsnotAccountSyncStrategy>D ActionsContactSyncStrategy> DDTO› D Fields•M lournalMetadata> C OpportunitySyncStrategyraclnation_ Prospectsearchstrateay• kedisv D service IraitsOpportunitysynclrait.phpusyncermentitieslrait.oho#suncrields.rait.ondT. Writecrmtrait.oho•D Utils• _ WebhookC) BatchSvncCollector.ooC) BatchSvncRedisService.oho© Client.phpC) ClosedDeaStadesService.onoC DealFieldsService.ohoC) DecorateActivitv.oho©) FieldDefinitions.ohv© FieldTypeConverter.php© HubspotClientinterface.php© HubspotTokenManager.php© PayloadBuilder.php© RemoteCrmObjectManipulator.phpd ResponseNormalize.php(©) Service.php© SyncFieldAction.php© SyncRelatedActivityManager.php© WebhookSyncBatchProcessor.phpv C IntegrationApp> D AccessorsAoil> D Confid• D Filters• ProsoectSearchStratedv• Service iraitsCActivityController.ongC BaseService.php© SoftPhoneManager.php(C) CoreUserRequest.onpscimProvistoning.ong© CoreUser.phpn44 o1285 05294 0306303 015326 @331336© ACtivity/.../Service.php© Crm/../Service.php xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV339 6>390 o>* Qinheritdocpublic function importPicklistValues(Field $field): array{...}* @important We only support stages on the opportunity object* Oparam stringlJlnull $typespublic function importStages(?array Stypes = null, ?string SmissingStageName = null): ?Stagel...}* dinherztdocpublic function syncLeads(Carbon Ssince, ?Carbon $to = null, ?string ScrmProfileId = null): int{..* dinheritdocnublic function suncleadstrina Scrmid): ?Lead.....* othrows Excentionpublic function syncAccounts(Carbon $since, ?Carbon $to = null): inti...}public function syncAccount(string $crmId): ?Account(...}Tusageprivate function importAccount(ScrmData): Account(...}* Othrows CloseExceptionpublic function syncOpportunities(array $parameters, ?string $strategy = null): int{...}public function syncOpportunity(string $crmId): ?0pportunity{...}2 usagesprivate function importOpportunity(OpportunitvMetadata ScrmData): 20pportunity{...}• aparam arrau<strina.string> Scrmbato* dparam stringl ScrmFields= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php x<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >1181304,39146 C)— 150|151 Gt :1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 2m left100% 1• Mon 18 May 16:58:42U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3View allapp/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Teams200-8UTF.8io 4 spaces...
|
NULL
|
7187235966032293681
|
NULL
|
click
|
ocr
|
NULL
|
PhostormVIeWINavicarecodeLaravelKeractorWindowFV f PhostormVIeWINavicarecodeLaravelKeractorWindowFV faVsco.js°9 master kProiectt..:C) StandardFieldMetadata,oho> _ CoopenCrmObiects> → DecorateActivity→ Dummy> → Helpersv MHubsnotAccountSyncStrategy>D ActionsContactSyncStrategy> DDTO› D Fields•M lournalMetadata> C OpportunitySyncStrategyraclnation_ Prospectsearchstrateay• kedisv D service IraitsOpportunitysynclrait.phpusyncermentitieslrait.oho#suncrields.rait.ondT. Writecrmtrait.oho•D Utils• _ WebhookC) BatchSvncCollector.ooC) BatchSvncRedisService.oho© Client.phpC) ClosedDeaStadesService.onoC DealFieldsService.ohoC) DecorateActivitv.oho©) FieldDefinitions.ohv© FieldTypeConverter.php© HubspotClientinterface.php© HubspotTokenManager.php© PayloadBuilder.php© RemoteCrmObjectManipulator.phpd ResponseNormalize.php(©) Service.php© SyncFieldAction.php© SyncRelatedActivityManager.php© WebhookSyncBatchProcessor.phpv C IntegrationApp> D AccessorsAoil> D Confid• D Filters• ProsoectSearchStratedv• Service iraitsCActivityController.ongC BaseService.php© SoftPhoneManager.php(C) CoreUserRequest.onpscimProvistoning.ong© CoreUser.phpn44 o1285 05294 0306303 015326 @331336© ACtivity/.../Service.php© Crm/../Service.php xclass Service extends BaseService 1mpLementsm A8 A39 M5 лV339 6>390 o>* Qinheritdocpublic function importPicklistValues(Field $field): array{...}* @important We only support stages on the opportunity object* Oparam stringlJlnull $typespublic function importStages(?array Stypes = null, ?string SmissingStageName = null): ?Stagel...}* dinherztdocpublic function syncLeads(Carbon Ssince, ?Carbon $to = null, ?string ScrmProfileId = null): int{..* dinheritdocnublic function suncleadstrina Scrmid): ?Lead.....* othrows Excentionpublic function syncAccounts(Carbon $since, ?Carbon $to = null): inti...}public function syncAccount(string $crmId): ?Account(...}Tusageprivate function importAccount(ScrmData): Account(...}* Othrows CloseExceptionpublic function syncOpportunities(array $parameters, ?string $strategy = null): int{...}public function syncOpportunity(string $crmId): ?0pportunity{...}2 usagesprivate function importOpportunity(OpportunitvMetadata ScrmData): 20pportunity{...}• aparam arrau<strina.string> Scrmbato* dparam stringl ScrmFields= custom.log= laravel.log4 SF jiminny@localhost]4 HS_local [jiminny@localhost]& console [PROD]# console [euyA console [STAGING]© CoachingFeedbackCoachUserln.php x<?phpdeclare(scrict_cypes=l)nnamespace Jiminny Component Activ1tySearch rilterbetin1t1on*› use ...final class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface29 Ф >34 0 >45 đ >1181304,39146 C)— 150|151 Gt :1usadeorivate const int No GROUP 10 = 9993 usagesorivate UserRenository SuserRenository:public function __construct(UserRepository $userRepository)f...hpublic function shouldApplyQueries: boolf...}public function getQueries: FilterDefinitionQueryCollection{...hpublic function toArray: array{...hprivate function getOptions(): array{...}public function getValue: array{...}private function getDefaultValue@: array{...}public function aetValidationRules(2strina Sorefix = null): arravs...?public function aetSortOrder@: intf...}public function shouldBeIncluded(Team $team): bool{...}[Platform] Refinemen... 2m left100% 1• Mon 18 May 16:58:42U AskJiminnyReportActivityServiceTest vCascadeImplement Trial OwneFixing Redis Rate LimSalestorce Token FalliSClM Role Manageme+0 ..inp on LineWARN Metadata found iin doc-comment for methodw9/ 10 tacke done• Write unit tests for SCIM role management10) Contact Mario to vodate customer10 / 10 tasks doneselm role managementtor leamtallor has been successtully implemented. Here's what was completed:Implementation Summary:1. CoreUserRequest DTO - Added role parsina from SCIM requests2. SCIM Constants - Added META ROLES and USER_PATCH_ATTR_ ROLES constants3. RoleAttr Mutator - Created mutator for handlina role assianments via SCIM PATCH operations4. ScimProvisioning - Updated to handle roles during user creation with proper validation5. ListenerRoleCannotHaveAdminOrManagerPermissionRule - Created validation rule to prevent listener role fromhaving admin/manager permissions6. CoreUser Response - Updated to include roles in SClM responses7. Unit Tests - Created comprehensive tests for the validation ruleKey Features:• Customers can now assign roles via SCIM (recorder, recorder & voice, analyst, listener, admin, manager)• Validation ensures listener role is onlv assianable if the LISTENER ROLE feature flaa is enabledl•LIstener role cannot de comolned with aamin or manager permissions• Uses existing UpdateUserRolesAction for role syncing and logging• Follows platform validation rules (DependentRolesRule, ListenerRoleRequiresListenerFeatureRule)Next Step: Please contact Mario to update the customer that SCIM role management is now available for Teamtailorintearationposwtn chaten vapp/Component/SClM/ Constants.php +3View allapp/Component/SCIM/ @ ScimProvisioning.php +85-15nse/ CoreUser.php +21ann/Comnonent/SCIM/Mutatore/Attributes/Ueer/M PoleAttr.nhn +17-ites/User/ ẞ RoleAttrTest.nhn +224* Reiect alliiAccent alliAsk anvthina (&4-L)« Code SWF-1.6W Windsurf Teams200-8UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55243
|
1914
|
12
|
2026-05-18T13:58:42.531807+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112722531_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% <78 • Mon 18 May 16:58:42=5Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:45:09...
|
NULL
|
-93094341451066657
|
NULL
|
click
|
ocr
|
NULL
|
FirefoxFileEditViewHistoryBookmarksProfilesToolsWi FirefoxFileEditViewHistoryBookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left100% <78 • Mon 18 May 16:58:42=5Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:45:09...
|
55241
|
NULL
|
NULL
|
NULL
|
|
55242
|
1915
|
6
|
2026-05-18T13:58:40.603584+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112720603_m2.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Close;
use Cache;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CloseInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Exceptions\UnexpectedCallException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Close\Processor\AccountProcessor;
use Jiminny\Services\Crm\Close\Processor\MetadataProcessor;
use Jiminny\Services\Crm\Close\Processor\OpportunityProcessor;
use Jiminny\Services\Crm\Close\Processor\StageProcessor;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\Metadata\OpportunityMetadata;
use Jiminny\Services\Crm\Metadata\ProfileMetadata;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Sentry;
use UnexpectedValueException;
class Service extends BaseService implements
CloseInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private StandardFieldMetadata $standardFieldMetadata;
private MetadataProcessor $metadataProcessor;
private FieldValueConverter $fieldValueConverter;
private StageProcessor $stageProcessor;
private OpportunityProcessor $opportunityProcessor;
private AccountProcessor $accountProcessor;
public function __construct(
Client $client,
StandardFieldMetadata $standardFieldMetadata,
MetadataProcessor $metadataProcessor,
FieldValueConverter $fieldValueConverter,
StageProcessor $stageResolver,
OpportunityProcessor $opportunityProcessor,
AccountProcessor $accountProcessor,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->standardFieldMetadata = $standardFieldMetadata;
$this->metadataProcessor = $metadataProcessor;
$this->fieldValueConverter = $fieldValueConverter;
$this->stageProcessor = $stageResolver;
$this->opportunityProcessor = $opportunityProcessor;
$this->accountProcessor = $accountProcessor;
}
public function getDisplayName(): string
{
return 'Close';
}
public function setConfiguration(Configuration $config): void
{
parent::setConfiguration($config);
$this->metadataProcessor->setConfiguration($config);
$this->stageProcessor->setConfiguration($config);
$this->opportunityProcessor->setConfiguration($config);
$this->accountProcessor->setConfiguration($config);
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);
}
private function getClient(): Client
{
if (! $this->client instanceof Client) {
throw new UnexpectedCallException('Client not set');
}
return $this->client;
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_OPPORTUNITY,
parent::OBJECT_CONTACT,
parent::OBJECT_ACCOUNT,
];
}
protected function getFields(string $crmObject): array
{
// not used
return [];
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Set up the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
/**
* @important Works only for custom fields
*/
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->getObjectType());
// We can only sync custom fields in this CRM.
if ($this->isCustomField($field->getCrmProviderId()) === false) {
return;
}
$crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());
$this->metadataProcessor->syncField($crmField);
}
private function isCustomField(string $fieldId): bool
{
return strpos($fieldId, 'cf_') === 0;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
// handled in syncFields()
return [];
}
/**
* @important We only support stages on the opportunity object
*
* @param string[]|null $types
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if (! $missingStageName) {
// This is taken care of by syncOrganization()
return null;
}
$stage = $this->stageProcessor->resolveFromStageId($missingStageName);
if ($stage instanceof Stage) {
return $stage;
}
$stageMetadata = $this->getClient()->fetchStage($missingStageName);
if (! $stageMetadata) {
$this->logger->error('Stage does not exist', [
'stage' => $missingStageName,
]);
return null;
}
return $this->stageProcessor->importStage($stageMetadata);
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Even though Close.io has the concept of "leads", they fit more into our concept of accounts.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Not a supported entity.
return null;
}
/**
* @throws Exception
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
foreach ($this->getClient()->listAccounts($since) as $clAccount) {
// Only sync if previously imported.
if ($this->hasAccount($clAccount->getId())) {
$this->importAccount($clAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
$this->logger->error('Account sync failed', [
'error' => $exception->getMessage(),
]);
throw $exception;
}
return $syncCount;
}
public function syncAccount(string $crmId): ?Account
{
return $this->accountProcessor->syncAccount($crmId);
}
private function importAccount($crmData): Account
{
return $this->accountProcessor->importAccountMetadata($crmData);
}
/**
* @throws CloseException
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
$opportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$opportunities[] = $opportunitiesData['data'];
if ($opportunitiesData['has_more']) {
$this->logger->info('[Close] Sync Opportunities - count warning', [
'team_id' => $this->config->getTeam()->getId(),
'total' => $opportunitiesData['total'],
'count' => $opportunitiesData['count'],
'skip' => $opportunitiesData['skip'],
'strategies_count' => count($strategies),
]);
}
}
$opportunities = array_merge(...$opportunities);
} catch (CrmException $exception) {
$this->logger->error('Fetching opportunity data failed', [
'team' => $this->getTeam()->getSlug(),
'error' => $exception->getMessage(),
]);
return 0;
}
foreach ($opportunities as $opportunityMetadata) {
try {
$this->importOpportunity($opportunityMetadata);
$syncCount++;
} catch (Exception $exception) {
$this->logger->warning('Opportunity sync failed', [
'opportunity' => $opportunityMetadata->getId(),
'error' => $exception->getMessage(),
]);
}
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
$opportunity = $strategy->fetchOpportunities($parameters);
if (empty($opportunity['data'])) {
return null;
}
return $this->importOpportunity($opportunity['data']);
}
private function importOpportunity(OpportunityMetadata $crmData): ?Opportunity
{
if (! $crmData->getLeadId()) {
$this->logger->warning('Opportunity does not have a lead ID', [
'opportunity' => $crmData->getId(),
]);
return null;
}
$account = $this->getConfiguration()
->accounts()
->where('crm_provider_id', $crmData->getLeadId())
->first();
if ($account === null) {
$account = $this->accountProcessor->syncAccount($crmData->getLeadId());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$stage = $this->getConfiguration()
->stages()
->where('crm_provider_id', $crmData->getStageId())
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());
}
return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);
}
/**
* @param array<string,string> $crmData
* @param string[] $crmFields
*/
public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void
{
// handled in importOpportunity
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
/** No way to sync today.
$clContacts = $this->client->get('lead', [
'date_updated__gte' => $since->toDateString(),
'_order_by' => '-date_updated',
]);
foreach ($clContacts as $clContact) {
// Only sync if previously imported.
if ($this->hasContact($clContact['id'])) {
$this->importContact($clContact);
$syncCount++;
}
}
**/
} catch (Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$clContact = $this->client->get('contact/' . $crmId);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($clContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['lead_id']) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['lead_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['lead_id']);
}
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phones'] as $phoneNumber) {
if ($phoneNumber['type'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);
} else {
$parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();
$data = [
'account_id' => $account->id ?? null,
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['updated_by'],
'name' => $crmData['name'] ?? 'Unknown',
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => Carbon::parse($crmData['date_created']),
];
/** @var Contact */
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
public function syncOrganization(): void
{
$organisation = $this->getClient()->fetchOrganisation();
$this->metadataProcessor->syncOrganisation($organisation);
foreach ($organisation->getPipelines() as $pipelineMetadata) {
$this->metadataProcessor->syncPipeline($pipelineMetadata);
}
}
private function syncStandardFields(): void
{
// Currently we sync only opportunity fields
$stages = $this->getClient()->listStages();
foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
$this->config->save();
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$objectType = $this->convertObjectTypeToResource($fieldType);
$currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);
foreach ($currentFields as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
}
$this->config->save();
}
public function syncProfiles(?User $userToSearch = null): ?Profile
{
/*
* Fetch the profile of the user from the database
* Then fetch the user metadata from Close and update it
* In case there's no profile for the user, proceed with syncing all users
*/
$foundUser = null;
if ($userToSearch) {
$profile = $userToSearch->getProfile();
if ($profile instanceof Profile) {
$crmProviderId = $profile->getCrmProviderId();
if ($crmProviderId) {
$profileMetadata = $this->getClient()->fetchUser($crmProviderId);
if (! $profileMetadata instanceof ProfileMetadata) {
return null;
}
return $this->metadataProcessor->syncProfile($profileMetadata);
}
}
}
foreach ($this->getClient()->listUsers() as $userMetadata) {
$userProfile = $this->metadataProcessor->syncProfile($userMetadata);
if (
$userToSearch instanceof User
&& $userProfile instanceof Profile
&& $userProfile->getUserId() === $userToSearch->getId()
) {
$foundUser = $userProfile;
}
}
return $foundUser;
}
public function syncProfileFields(): void
{
// Not used.
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {
$data = [];
try {
// If search phrase resembles phone number remove special symbols
if (preg_match('/^([0-9\s\-\+\(\)]*)$/', $name)) {
$name = '+' . preg_replace('/[\s\-\+\(\)]/', '', $name);
}
// Close do not provide a unified way to search, so we must hack our own.
$objects = $this->client->get('lead', [
'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',
'_limit' => $count, '_skip' => $offset,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
// We need a contact to dial it.
if (empty($object['contacts'])) {
continue;
}
foreach ($object['contacts'] as $contact) {
$record = [
'crmId' => $contact['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),
'name' => $contact['name'],
'industry' => null,
'title' => $contact['title'],
'organization' => $object['display_name'],
'prospectType' => 'contact',
'phoneNumbers' => [],
];
foreach ($contact['phones'] as $phone) {
if ($phone['type'] === 'mobile') {
$number = $this->buildContactMobilePhone(null, $phone['phone']);
$record['phoneNumbers'][] = [
'number' => $number,
'nationalFormat' => phone_national(null, $number),
'type' => 'mobile',
];
} else {
$parsedNumber = $this->buildContactPhone(null, $phone['phone']);
// Add phone number to record.
if (empty($parsedNumber['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national(null, $parsedNumber['phone']),
'type' => 'phone',
];
}
}
}
$data[] = $record;
}
}
return $data;
});
return $data;
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'lead_id' => $account->crm_provider_id,
'_order_by' => '-date_updated',
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['_order_by'] = '-date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['_order_by'] = 'date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
$onlyOpen = false;
}
if ($onlyOpen) {
$params['status_type__in'] = 'active,won';
}
$clOpportunities = $this->client->get('opportunity', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($clOpportunities['data'] as $clOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $clOpportunity['status_id'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);
}
$record = [
'crmId' => $clOpportunity['id'],
'name' => $clOpportunity['note'],
'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),
'won' => $stage->probability === 100.00,
'closed' => $clOpportunity['status_type'] !== 'active',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$crmId = null;
if ($objectType === 'contact') {
$contact = $this->syncContact($objectId);
if ($contact && $contact->account_id) {
$crmId = $contact->account->crm_provider_id;
}
} else {
$crmId = $objectId;
}
if ($crmId) {
$clTasks = $this->client->get('task', [
'lead_id' => $crmId,
'_type' => 'lead',
'assigned_to' => $this->profile->crm_provider_id,
'is_complete' => 'false',
'_order_by' => 'date',
]);
foreach ($clTasks['data'] as $clTask) {
$data[] = [
'crmId' => $clTask['id'],
'subject' => $clTask['text'],
'due' => $clTask['date'] ?? null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(email(email:"' . $email . '"))',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['emails'] as $clEmail) {
if ($email === $clEmail['email']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
// Check if the user is internal.
$teamMember = $this->team->users()->where('phone', $phone)->exists();
// Skip the attendee if internal.
if ($teamMember === false) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(' . $phone . ')',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['phones'] as $clPhone) {
if ($phone === $clPhone['phone']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(name:"' . $name . '")',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
if ($clContact['name'] === $name || $clContact['display_name'] === $name) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : false;
}
}
}
return false;
});
return is_array($result) ? $result : null;
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
private function convertCrmData(string $crmId, ?int $userId = null): array
{
$lead = null;
$opportunity = null;
$account = null;
$stage = null;
$countryCode = null;
$contact = $this->syncContact($crmId);
if ($contact) {
$account = $contact->account;
if ($contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account) {
$countryCode = $account->country_code;
}
try {
$cpOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId,
);
if (! empty($cpOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception) {
// Nothing to see here.
}
}
return [
$lead,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
public function saveActivity(Activity $activity): Activity
{
switch ($activity->type) {
case Activity::TYPE_CONFERENCE:
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$activity = $this->buildCallPayload($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$activity = $this->buildTextMessagePayload($activity);
break;
}
return $activity;
}
private function mapStatus(string $status): string
{
switch ($status) {
case Activity::STATUS_COMPLETED:
case Activity::STATUS_IN_PROGRESS:
case Activity::STATUS_FAILED:
case Activity::STATUS_NO_ANSWER:
case Activity::STATUS_BUSY:
default:
return $status;
case Activity::STATUS_CANCELLED:
return 'cancel';
}
}
/**
* @throws CrmException
*/
private function buildCallPayload(Activity $activity): Activity
{
try {
if ($activity->crm_provider_id) {
// The activity should be logged under the existing Task (not Activity).
$data = [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $this->generateActivityDescription($activity),
'date' => $activity->getActualEndTime()->toDateString(),
'is_complete' => true,
];
$this->logger->info('[Close CRM] Updating task', [
'activity' => $activity->id,
'crm_id' => $activity->crm_provider_id,
'data' => $data,
]);
$this->client->put('task/' . $activity->crm_provider_id, $data);
} else {
// Just create an activity.
$data = [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',
'status' => $this->mapStatus($activity->getStatus()),
'note' => $this->generateActivityDescription($activity),
'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,
'phone' => $activity->to ? $activity->to->phone_number : null,
];
$clActivity = $this->client->post('activity/call', $data);
$this->logger->info('[Close CRM] Creating activity', [
'activity' => $activity->id,
'crm_id' => $clActivity['id'],
'data' => $data,
'response' => $clActivity,
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
}
} catch (ClientException $exception) {
$response = $exception->getResponse();
if ($response === null) {
// Trying to debug weird cases where this is null.
Sentry::captureException($exception);
}
$responseBody = $response->getBody();
$message = $responseBody;
$errorCode = $response->getStatusCode();
$jsonResponse = json_decode($responseBody, true);
if (isset($jsonResponse[0]['message'])) {
$message = $jsonResponse[0]['message'];
}
throw new CrmException($message, $errorCode);
}
return $activity;
}
private function buildTextMessagePayload(Activity $activity): Activity
{
$clActivity = $this->client->post('activity/sms', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',
'text' => $this->generateActivityDescription($activity),
'remote_phone' => $activity->to ? $activity->to->phone_number : null,
'local_phone' => $activity->to ? $activity->to->phone_number : null,
'source' => 'Close.io',
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->hasActivityType()) {
$description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;
}
if ($activity->hasTitle()) {
$description .= $activity->getTitle() . PHP_EOL;
}
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;
// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;
} elseif ($activity->canReviewActivity()) {
$playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;
}
if ($activity->type === Activity::TYPE_CONFERENCE) {
$description .= 'Attendees:'
. PHP_EOL
. (new FilterJoinedParticipants())->toString($activity);
}
if (\count($activity->notes) > 0) {
$description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;
foreach ($activity->notes as $note) {
$time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);
$description .= $time . ' ' . $note->note . PHP_EOL;
}
}
// Get all private messages.
$messages = $activity->messages()
->where('is_private', 1)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
// Get all public messages.
$messages = $activity->messages()
->where('is_private', 0)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
if ($activity->summary) {
$description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;
}
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$description = $activity->description;
break;
}
return $description;
}
public function saveFollowupActivity(Activity $activity, array $fields): ?string
{
// This is the user provided activity subject field.
if (empty($fields['name'])) {
return null;
}
$due = null;
if (empty($fields['due_date']) === false) {
$formatDue = Carbon::parse($fields['due_date']);
$due = $formatDue->toDateTimeString();
}
$clTask = $this->client->post('task', [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $fields['name'],
'date' => $due,
'is_complete' => false,
]);
// We don't actually create a corresponding activity object on our side yet.
return $clTask['id'];
}
/**
* Store transcripts as note.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
if ($activity->account_id === null) {
// We can only log to accounts (leads).
return;
}
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);
$clActivity = $this->client->post('activity/note', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'note' => $transcripts,
]);
// Store CRM Activity ID in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $clActivity['id'];
$transcription->save();
}
public function parseObjectType(string $objectId): string
{
if (Str::startsWith($objectId, 'lead')) {
return 'account';
}
if (Str::startsWith($objectId, 'cont')) {
return 'contact';
}
if (Str::startsWith($objectId, 'oppo')) {
return 'opportunity';
}
throw new InvalidArgumentException('Unsupported Object Type');
}
/**
* @inheritdoc
*/
public function updateStage($crmObject, Stage $stage): void
{
if ($crmObject instanceof Lead) {
// This would never get invoked since we merge lead/accounts in Close.
$this->client->put('lead/' . $crmObject->crm_provider_id, [
'status' => $stage->crm_provider_id,
]);
} else {
$this->client->put('opportunity/' . $crmObject->crm_provider_id, [
'status_id' => $stage->crm_provider_id,
]);
}
}
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);
}
public function prepareValueForUpdate(array $params): array
{
$convertedValue = $this->fieldValueConverter->convertToCrm(
$this->config,
$params['fieldName'],
$params['fieldValue'],
);
if ($this->isCustomField($params['fieldName'])) {
$params['fieldName'] = 'custom.' . $params['fieldName'];
}
$params['fieldValue'] = $convertedValue;
return parent::prepareValueForUpdate($params);
}
public function getRecord(string $objectType, string $objectId, array $fields = []): array
{
return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);
}
/**
*
* @throws UnexpectedValueException
*/
private function convertObjectTypeToResource(string $objectType): string
{
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
return 'opportunity';
case FieldData::OBJECT_CONTACT:
return 'contact';
case FieldData::OBJECT_ACCOUNT:
return 'lead';
case FieldData::OBJECT_TASK:
return 'activity';
default:
throw new UnexpectedValueException('Unsupported object type "' . $objectType . '"');
}
}
public function generateProviderUrl(string $providerId, string $objectType): ?string
{
$baseUrl = 'https://app.close.com/';
$url = null;
switch ($objectType) {
case 'account':
$url = $baseUrl . 'lead/' . $providerId;
break;
case 'contact':
$contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();
if ($contact && $contact->account_id) {
$url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;
}
break;
default:
// Sadly we can't deeplink to anything else in Close UI.
$url = null;
}
return $url;
}
/**
* Generate transcription for the activity.
*/
private function generateTranscription(Activity $activity): string
{
if (! $this->config->store_transcript) {
// If sending transcription to activity toggle is disabled
return '';
}
return $this->transcriptionService
->findTranscriptionByActivity($activity)
->map(static function (array $transcriptionSegment): string {
return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];
})
->implode(PHP_EOL);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {
try {
$client = $this->getClient();
$task = $client->get('task/' . $crmProviderId);
return ! empty($task);
} catch (HttpNotFoundException) {
// Task not found in CRM - this is expected and permanent
$this->logger->info('[Close] Task not found during verification', [
'task_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
...
|
[{"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":"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/>114 incoming commits<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":"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":"8","depth":4,"bounds":{"left":0.3799867,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"39","depth":4,"bounds":{"left":0.3899601,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.40226063,"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.4119016,"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.4192154,"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\\Services\\Crm\\Close;\n\nuse Cache;\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Str;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CloseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Exceptions\\UnexpectedCallException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\AccountProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\MetadataProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\OpportunityProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\StageProcessor;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\Metadata\\OpportunityMetadata;\nuse Jiminny\\Services\\Crm\\Metadata\\ProfileMetadata;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Sentry;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n CloseInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n VerifyTaskExistsInterface\n{\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private StandardFieldMetadata $standardFieldMetadata;\n private MetadataProcessor $metadataProcessor;\n private FieldValueConverter $fieldValueConverter;\n private StageProcessor $stageProcessor;\n private OpportunityProcessor $opportunityProcessor;\n private AccountProcessor $accountProcessor;\n\n public function __construct(\n Client $client,\n StandardFieldMetadata $standardFieldMetadata,\n MetadataProcessor $metadataProcessor,\n FieldValueConverter $fieldValueConverter,\n StageProcessor $stageResolver,\n OpportunityProcessor $opportunityProcessor,\n AccountProcessor $accountProcessor,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->standardFieldMetadata = $standardFieldMetadata;\n $this->metadataProcessor = $metadataProcessor;\n $this->fieldValueConverter = $fieldValueConverter;\n $this->stageProcessor = $stageResolver;\n $this->opportunityProcessor = $opportunityProcessor;\n $this->accountProcessor = $accountProcessor;\n }\n\n public function getDisplayName(): string\n {\n return 'Close';\n }\n\n public function setConfiguration(Configuration $config): void\n {\n parent::setConfiguration($config);\n\n $this->metadataProcessor->setConfiguration($config);\n $this->stageProcessor->setConfiguration($config);\n $this->opportunityProcessor->setConfiguration($config);\n $this->accountProcessor->setConfiguration($config);\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);\n }\n\n private function getClient(): Client\n {\n if (! $this->client instanceof Client) {\n throw new UnexpectedCallException('Client not set');\n }\n\n return $this->client;\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_OPPORTUNITY,\n parent::OBJECT_CONTACT,\n parent::OBJECT_ACCOUNT,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n // not used\n return [];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Set up the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n /**\n * @important Works only for custom fields\n */\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->getObjectType());\n\n // We can only sync custom fields in this CRM.\n if ($this->isCustomField($field->getCrmProviderId()) === false) {\n return;\n }\n\n $crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());\n\n $this->metadataProcessor->syncField($crmField);\n }\n\n private function isCustomField(string $fieldId): bool\n {\n return strpos($fieldId, 'cf_') === 0;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n // handled in syncFields()\n return [];\n }\n\n /**\n * @important We only support stages on the opportunity object\n *\n * @param string[]|null $types\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if (! $missingStageName) {\n // This is taken care of by syncOrganization()\n return null;\n }\n\n $stage = $this->stageProcessor->resolveFromStageId($missingStageName);\n\n if ($stage instanceof Stage) {\n return $stage;\n }\n\n $stageMetadata = $this->getClient()->fetchStage($missingStageName);\n\n if (! $stageMetadata) {\n $this->logger->error('Stage does not exist', [\n 'stage' => $missingStageName,\n ]);\n\n return null;\n }\n\n\n return $this->stageProcessor->importStage($stageMetadata);\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Even though Close.io has the concept of \"leads\", they fit more into our concept of accounts.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Not a supported entity.\n return null;\n }\n\n /**\n * @throws Exception\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n foreach ($this->getClient()->listAccounts($since) as $clAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($clAccount->getId())) {\n $this->importAccount($clAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n $this->logger->error('Account sync failed', [\n 'error' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n return $syncCount;\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n return $this->accountProcessor->syncAccount($crmId);\n }\n\n private function importAccount($crmData): Account\n {\n return $this->accountProcessor->importAccountMetadata($crmData);\n }\n\n /**\n * @throws CloseException\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n $opportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $opportunities[] = $opportunitiesData['data'];\n\n if ($opportunitiesData['has_more']) {\n $this->logger->info('[Close] Sync Opportunities - count warning', [\n 'team_id' => $this->config->getTeam()->getId(),\n 'total' => $opportunitiesData['total'],\n 'count' => $opportunitiesData['count'],\n 'skip' => $opportunitiesData['skip'],\n 'strategies_count' => count($strategies),\n ]);\n }\n }\n\n $opportunities = array_merge(...$opportunities);\n } catch (CrmException $exception) {\n $this->logger->error('Fetching opportunity data failed', [\n 'team' => $this->getTeam()->getSlug(),\n 'error' => $exception->getMessage(),\n ]);\n\n return 0;\n }\n\n foreach ($opportunities as $opportunityMetadata) {\n try {\n $this->importOpportunity($opportunityMetadata);\n $syncCount++;\n } catch (Exception $exception) {\n $this->logger->warning('Opportunity sync failed', [\n 'opportunity' => $opportunityMetadata->getId(),\n 'error' => $exception->getMessage(),\n ]);\n }\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n $opportunity = $strategy->fetchOpportunities($parameters);\n\n if (empty($opportunity['data'])) {\n return null;\n }\n\n return $this->importOpportunity($opportunity['data']);\n }\n\n private function importOpportunity(OpportunityMetadata $crmData): ?Opportunity\n {\n if (! $crmData->getLeadId()) {\n $this->logger->warning('Opportunity does not have a lead ID', [\n 'opportunity' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $account = $this->getConfiguration()\n ->accounts()\n ->where('crm_provider_id', $crmData->getLeadId())\n ->first();\n\n if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $stage = $this->getConfiguration()\n ->stages()\n ->where('crm_provider_id', $crmData->getStageId())\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());\n }\n\n return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);\n }\n\n /**\n * @param array<string,string> $crmData\n * @param string[] $crmFields\n */\n public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void\n {\n // handled in importOpportunity\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n /** No way to sync today.\n $clContacts = $this->client->get('lead', [\n 'date_updated__gte' => $since->toDateString(),\n '_order_by' => '-date_updated',\n ]);\n\n foreach ($clContacts as $clContact) {\n // Only sync if previously imported.\n if ($this->hasContact($clContact['id'])) {\n $this->importContact($clContact);\n $syncCount++;\n }\n }\n **/\n } catch (Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $clContact = $this->client->get('contact/' . $crmId);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($clContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['lead_id']) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['lead_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['lead_id']);\n }\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phones'] as $phoneNumber) {\n if ($phoneNumber['type'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();\n\n $data = [\n 'account_id' => $account->id ?? null,\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['updated_by'],\n 'name' => $crmData['name'] ?? 'Unknown',\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => Carbon::parse($crmData['date_created']),\n ];\n\n /** @var Contact */\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n public function syncOrganization(): void\n {\n $organisation = $this->getClient()->fetchOrganisation();\n\n $this->metadataProcessor->syncOrganisation($organisation);\n\n foreach ($organisation->getPipelines() as $pipelineMetadata) {\n $this->metadataProcessor->syncPipeline($pipelineMetadata);\n }\n }\n\n private function syncStandardFields(): void\n {\n // Currently we sync only opportunity fields\n $stages = $this->getClient()->listStages();\n foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n\n $this->config->save();\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $objectType = $this->convertObjectTypeToResource($fieldType);\n $currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);\n\n foreach ($currentFields as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n }\n\n $this->config->save();\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n /*\n * Fetch the profile of the user from the database\n * Then fetch the user metadata from Close and update it\n * In case there's no profile for the user, proceed with syncing all users\n */\n $foundUser = null;\n\n if ($userToSearch) {\n $profile = $userToSearch->getProfile();\n\n if ($profile instanceof Profile) {\n $crmProviderId = $profile->getCrmProviderId();\n\n if ($crmProviderId) {\n $profileMetadata = $this->getClient()->fetchUser($crmProviderId);\n\n if (! $profileMetadata instanceof ProfileMetadata) {\n return null;\n }\n\n return $this->metadataProcessor->syncProfile($profileMetadata);\n }\n }\n }\n\n foreach ($this->getClient()->listUsers() as $userMetadata) {\n $userProfile = $this->metadataProcessor->syncProfile($userMetadata);\n\n if (\n $userToSearch instanceof User\n && $userProfile instanceof Profile\n && $userProfile->getUserId() === $userToSearch->getId()\n ) {\n $foundUser = $userProfile;\n }\n }\n\n return $foundUser;\n }\n\n public function syncProfileFields(): void\n {\n // Not used.\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {\n $data = [];\n\n try {\n // If search phrase resembles phone number remove special symbols\n if (preg_match('/^([0-9\\s\\-\\+\\(\\)]*)$/', $name)) {\n $name = '+' . preg_replace('/[\\s\\-\\+\\(\\)]/', '', $name);\n }\n\n // Close do not provide a unified way to search, so we must hack our own.\n $objects = $this->client->get('lead', [\n 'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',\n '_limit' => $count, '_skip' => $offset,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n // We need a contact to dial it.\n if (empty($object['contacts'])) {\n continue;\n }\n\n foreach ($object['contacts'] as $contact) {\n $record = [\n 'crmId' => $contact['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),\n 'name' => $contact['name'],\n 'industry' => null,\n 'title' => $contact['title'],\n 'organization' => $object['display_name'],\n 'prospectType' => 'contact',\n 'phoneNumbers' => [],\n ];\n\n foreach ($contact['phones'] as $phone) {\n if ($phone['type'] === 'mobile') {\n $number = $this->buildContactMobilePhone(null, $phone['phone']);\n\n $record['phoneNumbers'][] = [\n 'number' => $number,\n 'nationalFormat' => phone_national(null, $number),\n 'type' => 'mobile',\n ];\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phone['phone']);\n\n // Add phone number to record.\n if (empty($parsedNumber['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national(null, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n }\n }\n\n $data[] = $record;\n }\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'lead_id' => $account->crm_provider_id,\n '_order_by' => '-date_updated',\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['_order_by'] = '-date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['_order_by'] = 'date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $params['status_type__in'] = 'active,won';\n }\n\n $clOpportunities = $this->client->get('opportunity', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($clOpportunities['data'] as $clOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $clOpportunity['status_id'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);\n }\n\n $record = [\n 'crmId' => $clOpportunity['id'],\n 'name' => $clOpportunity['note'],\n 'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),\n 'won' => $stage->probability === 100.00,\n 'closed' => $clOpportunity['status_type'] !== 'active',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $crmId = null;\n\n if ($objectType === 'contact') {\n $contact = $this->syncContact($objectId);\n\n if ($contact && $contact->account_id) {\n $crmId = $contact->account->crm_provider_id;\n }\n } else {\n $crmId = $objectId;\n }\n\n if ($crmId) {\n $clTasks = $this->client->get('task', [\n 'lead_id' => $crmId,\n '_type' => 'lead',\n 'assigned_to' => $this->profile->crm_provider_id,\n 'is_complete' => 'false',\n '_order_by' => 'date',\n ]);\n\n foreach ($clTasks['data'] as $clTask) {\n $data[] = [\n 'crmId' => $clTask['id'],\n 'subject' => $clTask['text'],\n 'due' => $clTask['date'] ?? null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(email(email:\"' . $email . '\"))',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['emails'] as $clEmail) {\n if ($email === $clEmail['email']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(' . $phone . ')',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['phones'] as $clPhone) {\n if ($phone === $clPhone['phone']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(name:\"' . $name . '\")',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n if ($clContact['name'] === $name || $clContact['display_name'] === $name) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n }\n }\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n private function convertCrmData(string $crmId, ?int $userId = null): array\n {\n $lead = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $contact = $this->syncContact($crmId);\n if ($contact) {\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case Activity::STATUS_COMPLETED:\n case Activity::STATUS_IN_PROGRESS:\n case Activity::STATUS_FAILED:\n case Activity::STATUS_NO_ANSWER:\n case Activity::STATUS_BUSY:\n default:\n return $status;\n case Activity::STATUS_CANCELLED:\n return 'cancel';\n }\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $data = [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $this->generateActivityDescription($activity),\n 'date' => $activity->getActualEndTime()->toDateString(),\n 'is_complete' => true,\n ];\n\n $this->logger->info('[Close CRM] Updating task', [\n 'activity' => $activity->id,\n 'crm_id' => $activity->crm_provider_id,\n 'data' => $data,\n ]);\n\n $this->client->put('task/' . $activity->crm_provider_id, $data);\n } else {\n // Just create an activity.\n $data = [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',\n 'status' => $this->mapStatus($activity->getStatus()),\n 'note' => $this->generateActivityDescription($activity),\n 'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,\n 'phone' => $activity->to ? $activity->to->phone_number : null,\n ];\n\n $clActivity = $this->client->post('activity/call', $data);\n\n $this->logger->info('[Close CRM] Creating activity', [\n 'activity' => $activity->id,\n 'crm_id' => $clActivity['id'],\n 'data' => $data,\n 'response' => $clActivity,\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n $response = $exception->getResponse();\n\n if ($response === null) {\n // Trying to debug weird cases where this is null.\n Sentry::captureException($exception);\n }\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $clActivity = $this->client->post('activity/sms', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',\n 'text' => $this->generateActivityDescription($activity),\n 'remote_phone' => $activity->to ? $activity->to->phone_number : null,\n 'local_phone' => $activity->to ? $activity->to->phone_number : null,\n 'source' => 'Close.io',\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n if ($activity->hasActivityType()) {\n $description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;\n }\n if ($activity->hasTitle()) {\n $description .= $activity->getTitle() . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['name'])) {\n return null;\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::parse($fields['due_date']);\n $due = $formatDue->toDateTimeString();\n }\n\n $clTask = $this->client->post('task', [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $fields['name'],\n 'date' => $due,\n 'is_complete' => false,\n ]);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $clTask['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n if ($activity->account_id === null) {\n // We can only log to accounts (leads).\n return;\n }\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $clActivity = $this->client->post('activity/note', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'note' => $transcripts,\n ]);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $clActivity['id'];\n $transcription->save();\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, 'lead')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, 'cont')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, 'oppo')) {\n return 'opportunity';\n }\n\n throw new InvalidArgumentException('Unsupported Object Type');\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n // This would never get invoked since we merge lead/accounts in Close.\n $this->client->put('lead/' . $crmObject->crm_provider_id, [\n 'status' => $stage->crm_provider_id,\n ]);\n } else {\n $this->client->put('opportunity/' . $crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n $convertedValue = $this->fieldValueConverter->convertToCrm(\n $this->config,\n $params['fieldName'],\n $params['fieldValue'],\n );\n\n if ($this->isCustomField($params['fieldName'])) {\n $params['fieldName'] = 'custom.' . $params['fieldName'];\n }\n\n $params['fieldValue'] = $convertedValue;\n\n return parent::prepareValueForUpdate($params);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);\n }\n\n /**\n *\n * @throws UnexpectedValueException\n */\n private function convertObjectTypeToResource(string $objectType): string\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return 'opportunity';\n\n case FieldData::OBJECT_CONTACT:\n return 'contact';\n\n case FieldData::OBJECT_ACCOUNT:\n return 'lead';\n\n case FieldData::OBJECT_TASK:\n return 'activity';\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.close.com/';\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'lead/' . $providerId;\n\n break;\n\n case 'contact':\n $contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();\n if ($contact && $contact->account_id) {\n $url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;\n }\n\n break;\n\n default:\n // Sadly we can't deeplink to anything else in Close UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $client = $this->getClient();\n $task = $client->get('task/' . $crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Close] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (CloseException $e) {\n // Handle 404 responses from Close API\n if ($e->getResponseStatusCode() === 404) {\n $this->logger->info('[Close] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Close exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Close;\n\nuse Cache;\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Str;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CloseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Exceptions\\UnexpectedCallException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\AccountProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\MetadataProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\OpportunityProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\StageProcessor;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\Metadata\\OpportunityMetadata;\nuse Jiminny\\Services\\Crm\\Metadata\\ProfileMetadata;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Sentry;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n CloseInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n VerifyTaskExistsInterface\n{\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private StandardFieldMetadata $standardFieldMetadata;\n private MetadataProcessor $metadataProcessor;\n private FieldValueConverter $fieldValueConverter;\n private StageProcessor $stageProcessor;\n private OpportunityProcessor $opportunityProcessor;\n private AccountProcessor $accountProcessor;\n\n public function __construct(\n Client $client,\n StandardFieldMetadata $standardFieldMetadata,\n MetadataProcessor $metadataProcessor,\n FieldValueConverter $fieldValueConverter,\n StageProcessor $stageResolver,\n OpportunityProcessor $opportunityProcessor,\n AccountProcessor $accountProcessor,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->standardFieldMetadata = $standardFieldMetadata;\n $this->metadataProcessor = $metadataProcessor;\n $this->fieldValueConverter = $fieldValueConverter;\n $this->stageProcessor = $stageResolver;\n $this->opportunityProcessor = $opportunityProcessor;\n $this->accountProcessor = $accountProcessor;\n }\n\n public function getDisplayName(): string\n {\n return 'Close';\n }\n\n public function setConfiguration(Configuration $config): void\n {\n parent::setConfiguration($config);\n\n $this->metadataProcessor->setConfiguration($config);\n $this->stageProcessor->setConfiguration($config);\n $this->opportunityProcessor->setConfiguration($config);\n $this->accountProcessor->setConfiguration($config);\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);\n }\n\n private function getClient(): Client\n {\n if (! $this->client instanceof Client) {\n throw new UnexpectedCallException('Client not set');\n }\n\n return $this->client;\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_OPPORTUNITY,\n parent::OBJECT_CONTACT,\n parent::OBJECT_ACCOUNT,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n // not used\n return [];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Set up the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n /**\n * @important Works only for custom fields\n */\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->getObjectType());\n\n // We can only sync custom fields in this CRM.\n if ($this->isCustomField($field->getCrmProviderId()) === false) {\n return;\n }\n\n $crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());\n\n $this->metadataProcessor->syncField($crmField);\n }\n\n private function isCustomField(string $fieldId): bool\n {\n return strpos($fieldId, 'cf_') === 0;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n // handled in syncFields()\n return [];\n }\n\n /**\n * @important We only support stages on the opportunity object\n *\n * @param string[]|null $types\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if (! $missingStageName) {\n // This is taken care of by syncOrganization()\n return null;\n }\n\n $stage = $this->stageProcessor->resolveFromStageId($missingStageName);\n\n if ($stage instanceof Stage) {\n return $stage;\n }\n\n $stageMetadata = $this->getClient()->fetchStage($missingStageName);\n\n if (! $stageMetadata) {\n $this->logger->error('Stage does not exist', [\n 'stage' => $missingStageName,\n ]);\n\n return null;\n }\n\n\n return $this->stageProcessor->importStage($stageMetadata);\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Even though Close.io has the concept of \"leads\", they fit more into our concept of accounts.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Not a supported entity.\n return null;\n }\n\n /**\n * @throws Exception\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n foreach ($this->getClient()->listAccounts($since) as $clAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($clAccount->getId())) {\n $this->importAccount($clAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n $this->logger->error('Account sync failed', [\n 'error' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n return $syncCount;\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n return $this->accountProcessor->syncAccount($crmId);\n }\n\n private function importAccount($crmData): Account\n {\n return $this->accountProcessor->importAccountMetadata($crmData);\n }\n\n /**\n * @throws CloseException\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n $opportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $opportunities[] = $opportunitiesData['data'];\n\n if ($opportunitiesData['has_more']) {\n $this->logger->info('[Close] Sync Opportunities - count warning', [\n 'team_id' => $this->config->getTeam()->getId(),\n 'total' => $opportunitiesData['total'],\n 'count' => $opportunitiesData['count'],\n 'skip' => $opportunitiesData['skip'],\n 'strategies_count' => count($strategies),\n ]);\n }\n }\n\n $opportunities = array_merge(...$opportunities);\n } catch (CrmException $exception) {\n $this->logger->error('Fetching opportunity data failed', [\n 'team' => $this->getTeam()->getSlug(),\n 'error' => $exception->getMessage(),\n ]);\n\n return 0;\n }\n\n foreach ($opportunities as $opportunityMetadata) {\n try {\n $this->importOpportunity($opportunityMetadata);\n $syncCount++;\n } catch (Exception $exception) {\n $this->logger->warning('Opportunity sync failed', [\n 'opportunity' => $opportunityMetadata->getId(),\n 'error' => $exception->getMessage(),\n ]);\n }\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n $opportunity = $strategy->fetchOpportunities($parameters);\n\n if (empty($opportunity['data'])) {\n return null;\n }\n\n return $this->importOpportunity($opportunity['data']);\n }\n\n private function importOpportunity(OpportunityMetadata $crmData): ?Opportunity\n {\n if (! $crmData->getLeadId()) {\n $this->logger->warning('Opportunity does not have a lead ID', [\n 'opportunity' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $account = $this->getConfiguration()\n ->accounts()\n ->where('crm_provider_id', $crmData->getLeadId())\n ->first();\n\n if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $stage = $this->getConfiguration()\n ->stages()\n ->where('crm_provider_id', $crmData->getStageId())\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());\n }\n\n return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);\n }\n\n /**\n * @param array<string,string> $crmData\n * @param string[] $crmFields\n */\n public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void\n {\n // handled in importOpportunity\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n /** No way to sync today.\n $clContacts = $this->client->get('lead', [\n 'date_updated__gte' => $since->toDateString(),\n '_order_by' => '-date_updated',\n ]);\n\n foreach ($clContacts as $clContact) {\n // Only sync if previously imported.\n if ($this->hasContact($clContact['id'])) {\n $this->importContact($clContact);\n $syncCount++;\n }\n }\n **/\n } catch (Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $clContact = $this->client->get('contact/' . $crmId);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($clContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['lead_id']) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['lead_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['lead_id']);\n }\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phones'] as $phoneNumber) {\n if ($phoneNumber['type'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();\n\n $data = [\n 'account_id' => $account->id ?? null,\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['updated_by'],\n 'name' => $crmData['name'] ?? 'Unknown',\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => Carbon::parse($crmData['date_created']),\n ];\n\n /** @var Contact */\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n public function syncOrganization(): void\n {\n $organisation = $this->getClient()->fetchOrganisation();\n\n $this->metadataProcessor->syncOrganisation($organisation);\n\n foreach ($organisation->getPipelines() as $pipelineMetadata) {\n $this->metadataProcessor->syncPipeline($pipelineMetadata);\n }\n }\n\n private function syncStandardFields(): void\n {\n // Currently we sync only opportunity fields\n $stages = $this->getClient()->listStages();\n foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n\n $this->config->save();\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $objectType = $this->convertObjectTypeToResource($fieldType);\n $currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);\n\n foreach ($currentFields as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n }\n\n $this->config->save();\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n /*\n * Fetch the profile of the user from the database\n * Then fetch the user metadata from Close and update it\n * In case there's no profile for the user, proceed with syncing all users\n */\n $foundUser = null;\n\n if ($userToSearch) {\n $profile = $userToSearch->getProfile();\n\n if ($profile instanceof Profile) {\n $crmProviderId = $profile->getCrmProviderId();\n\n if ($crmProviderId) {\n $profileMetadata = $this->getClient()->fetchUser($crmProviderId);\n\n if (! $profileMetadata instanceof ProfileMetadata) {\n return null;\n }\n\n return $this->metadataProcessor->syncProfile($profileMetadata);\n }\n }\n }\n\n foreach ($this->getClient()->listUsers() as $userMetadata) {\n $userProfile = $this->metadataProcessor->syncProfile($userMetadata);\n\n if (\n $userToSearch instanceof User\n && $userProfile instanceof Profile\n && $userProfile->getUserId() === $userToSearch->getId()\n ) {\n $foundUser = $userProfile;\n }\n }\n\n return $foundUser;\n }\n\n public function syncProfileFields(): void\n {\n // Not used.\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {\n $data = [];\n\n try {\n // If search phrase resembles phone number remove special symbols\n if (preg_match('/^([0-9\\s\\-\\+\\(\\)]*)$/', $name)) {\n $name = '+' . preg_replace('/[\\s\\-\\+\\(\\)]/', '', $name);\n }\n\n // Close do not provide a unified way to search, so we must hack our own.\n $objects = $this->client->get('lead', [\n 'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',\n '_limit' => $count, '_skip' => $offset,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n // We need a contact to dial it.\n if (empty($object['contacts'])) {\n continue;\n }\n\n foreach ($object['contacts'] as $contact) {\n $record = [\n 'crmId' => $contact['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),\n 'name' => $contact['name'],\n 'industry' => null,\n 'title' => $contact['title'],\n 'organization' => $object['display_name'],\n 'prospectType' => 'contact',\n 'phoneNumbers' => [],\n ];\n\n foreach ($contact['phones'] as $phone) {\n if ($phone['type'] === 'mobile') {\n $number = $this->buildContactMobilePhone(null, $phone['phone']);\n\n $record['phoneNumbers'][] = [\n 'number' => $number,\n 'nationalFormat' => phone_national(null, $number),\n 'type' => 'mobile',\n ];\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phone['phone']);\n\n // Add phone number to record.\n if (empty($parsedNumber['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national(null, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n }\n }\n\n $data[] = $record;\n }\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'lead_id' => $account->crm_provider_id,\n '_order_by' => '-date_updated',\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['_order_by'] = '-date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['_order_by'] = 'date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $params['status_type__in'] = 'active,won';\n }\n\n $clOpportunities = $this->client->get('opportunity', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($clOpportunities['data'] as $clOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $clOpportunity['status_id'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);\n }\n\n $record = [\n 'crmId' => $clOpportunity['id'],\n 'name' => $clOpportunity['note'],\n 'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),\n 'won' => $stage->probability === 100.00,\n 'closed' => $clOpportunity['status_type'] !== 'active',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $crmId = null;\n\n if ($objectType === 'contact') {\n $contact = $this->syncContact($objectId);\n\n if ($contact && $contact->account_id) {\n $crmId = $contact->account->crm_provider_id;\n }\n } else {\n $crmId = $objectId;\n }\n\n if ($crmId) {\n $clTasks = $this->client->get('task', [\n 'lead_id' => $crmId,\n '_type' => 'lead',\n 'assigned_to' => $this->profile->crm_provider_id,\n 'is_complete' => 'false',\n '_order_by' => 'date',\n ]);\n\n foreach ($clTasks['data'] as $clTask) {\n $data[] = [\n 'crmId' => $clTask['id'],\n 'subject' => $clTask['text'],\n 'due' => $clTask['date'] ?? null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(email(email:\"' . $email . '\"))',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['emails'] as $clEmail) {\n if ($email === $clEmail['email']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(' . $phone . ')',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['phones'] as $clPhone) {\n if ($phone === $clPhone['phone']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(name:\"' . $name . '\")',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n if ($clContact['name'] === $name || $clContact['display_name'] === $name) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n }\n }\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n private function convertCrmData(string $crmId, ?int $userId = null): array\n {\n $lead = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $contact = $this->syncContact($crmId);\n if ($contact) {\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case Activity::STATUS_COMPLETED:\n case Activity::STATUS_IN_PROGRESS:\n case Activity::STATUS_FAILED:\n case Activity::STATUS_NO_ANSWER:\n case Activity::STATUS_BUSY:\n default:\n return $status;\n case Activity::STATUS_CANCELLED:\n return 'cancel';\n }\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $data = [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $this->generateActivityDescription($activity),\n 'date' => $activity->getActualEndTime()->toDateString(),\n 'is_complete' => true,\n ];\n\n $this->logger->info('[Close CRM] Updating task', [\n 'activity' => $activity->id,\n 'crm_id' => $activity->crm_provider_id,\n 'data' => $data,\n ]);\n\n $this->client->put('task/' . $activity->crm_provider_id, $data);\n } else {\n // Just create an activity.\n $data = [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',\n 'status' => $this->mapStatus($activity->getStatus()),\n 'note' => $this->generateActivityDescription($activity),\n 'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,\n 'phone' => $activity->to ? $activity->to->phone_number : null,\n ];\n\n $clActivity = $this->client->post('activity/call', $data);\n\n $this->logger->info('[Close CRM] Creating activity', [\n 'activity' => $activity->id,\n 'crm_id' => $clActivity['id'],\n 'data' => $data,\n 'response' => $clActivity,\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n $response = $exception->getResponse();\n\n if ($response === null) {\n // Trying to debug weird cases where this is null.\n Sentry::captureException($exception);\n }\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $clActivity = $this->client->post('activity/sms', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',\n 'text' => $this->generateActivityDescription($activity),\n 'remote_phone' => $activity->to ? $activity->to->phone_number : null,\n 'local_phone' => $activity->to ? $activity->to->phone_number : null,\n 'source' => 'Close.io',\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n if ($activity->hasActivityType()) {\n $description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;\n }\n if ($activity->hasTitle()) {\n $description .= $activity->getTitle() . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['name'])) {\n return null;\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::parse($fields['due_date']);\n $due = $formatDue->toDateTimeString();\n }\n\n $clTask = $this->client->post('task', [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $fields['name'],\n 'date' => $due,\n 'is_complete' => false,\n ]);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $clTask['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n if ($activity->account_id === null) {\n // We can only log to accounts (leads).\n return;\n }\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $clActivity = $this->client->post('activity/note', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'note' => $transcripts,\n ]);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $clActivity['id'];\n $transcription->save();\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, 'lead')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, 'cont')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, 'oppo')) {\n return 'opportunity';\n }\n\n throw new InvalidArgumentException('Unsupported Object Type');\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n // This would never get invoked since we merge lead/accounts in Close.\n $this->client->put('lead/' . $crmObject->crm_provider_id, [\n 'status' => $stage->crm_provider_id,\n ]);\n } else {\n $this->client->put('opportunity/' . $crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n $convertedValue = $this->fieldValueConverter->convertToCrm(\n $this->config,\n $params['fieldName'],\n $params['fieldValue'],\n );\n\n if ($this->isCustomField($params['fieldName'])) {\n $params['fieldName'] = 'custom.' . $params['fieldName'];\n }\n\n $params['fieldValue'] = $convertedValue;\n\n return parent::prepareValueForUpdate($params);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);\n }\n\n /**\n *\n * @throws UnexpectedValueException\n */\n private function convertObjectTypeToResource(string $objectType): string\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return 'opportunity';\n\n case FieldData::OBJECT_CONTACT:\n return 'contact';\n\n case FieldData::OBJECT_ACCOUNT:\n return 'lead';\n\n case FieldData::OBJECT_TASK:\n return 'activity';\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.close.com/';\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'lead/' . $providerId;\n\n break;\n\n case 'contact':\n $contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();\n if ($contact && $contact->account_id) {\n $url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;\n }\n\n break;\n\n default:\n // Sadly we can't deeplink to anything else in Close UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $client = $this->getClient();\n $task = $client->get('task/' . $crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Close] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (CloseException $e) {\n // Handle 404 responses from Close API\n if ($e->getResponseStatusCode() === 404) {\n $this->logger->info('[Close] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Close exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"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}]...
|
-6754415607117048428
|
-9030663327281178587
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Close;
use Cache;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CloseInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Exceptions\UnexpectedCallException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Close\Processor\AccountProcessor;
use Jiminny\Services\Crm\Close\Processor\MetadataProcessor;
use Jiminny\Services\Crm\Close\Processor\OpportunityProcessor;
use Jiminny\Services\Crm\Close\Processor\StageProcessor;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\Metadata\OpportunityMetadata;
use Jiminny\Services\Crm\Metadata\ProfileMetadata;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Sentry;
use UnexpectedValueException;
class Service extends BaseService implements
CloseInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private StandardFieldMetadata $standardFieldMetadata;
private MetadataProcessor $metadataProcessor;
private FieldValueConverter $fieldValueConverter;
private StageProcessor $stageProcessor;
private OpportunityProcessor $opportunityProcessor;
private AccountProcessor $accountProcessor;
public function __construct(
Client $client,
StandardFieldMetadata $standardFieldMetadata,
MetadataProcessor $metadataProcessor,
FieldValueConverter $fieldValueConverter,
StageProcessor $stageResolver,
OpportunityProcessor $opportunityProcessor,
AccountProcessor $accountProcessor,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->standardFieldMetadata = $standardFieldMetadata;
$this->metadataProcessor = $metadataProcessor;
$this->fieldValueConverter = $fieldValueConverter;
$this->stageProcessor = $stageResolver;
$this->opportunityProcessor = $opportunityProcessor;
$this->accountProcessor = $accountProcessor;
}
public function getDisplayName(): string
{
return 'Close';
}
public function setConfiguration(Configuration $config): void
{
parent::setConfiguration($config);
$this->metadataProcessor->setConfiguration($config);
$this->stageProcessor->setConfiguration($config);
$this->opportunityProcessor->setConfiguration($config);
$this->accountProcessor->setConfiguration($config);
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);
}
private function getClient(): Client
{
if (! $this->client instanceof Client) {
throw new UnexpectedCallException('Client not set');
}
return $this->client;
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_OPPORTUNITY,
parent::OBJECT_CONTACT,
parent::OBJECT_ACCOUNT,
];
}
protected function getFields(string $crmObject): array
{
// not used
return [];
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Set up the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
/**
* @important Works only for custom fields
*/
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->getObjectType());
// We can only sync custom fields in this CRM.
if ($this->isCustomField($field->getCrmProviderId()) === false) {
return;
}
$crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());
$this->metadataProcessor->syncField($crmField);
}
private function isCustomField(string $fieldId): bool
{
return strpos($fieldId, 'cf_') === 0;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
// handled in syncFields()
return [];
}
/**
* @important We only support stages on the opportunity object
*
* @param string[]|null $types
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if (! $missingStageName) {
// This is taken care of by syncOrganization()
return null;
}
$stage = $this->stageProcessor->resolveFromStageId($missingStageName);
if ($stage instanceof Stage) {
return $stage;
}
$stageMetadata = $this->getClient()->fetchStage($missingStageName);
if (! $stageMetadata) {
$this->logger->error('Stage does not exist', [
'stage' => $missingStageName,
]);
return null;
}
return $this->stageProcessor->importStage($stageMetadata);
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Even though Close.io has the concept of "leads", they fit more into our concept of accounts.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Not a supported entity.
return null;
}
/**
* @throws Exception
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
foreach ($this->getClient()->listAccounts($since) as $clAccount) {
// Only sync if previously imported.
if ($this->hasAccount($clAccount->getId())) {
$this->importAccount($clAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
$this->logger->error('Account sync failed', [
'error' => $exception->getMessage(),
]);
throw $exception;
}
return $syncCount;
}
public function syncAccount(string $crmId): ?Account
{
return $this->accountProcessor->syncAccount($crmId);
}
private function importAccount($crmData): Account
{
return $this->accountProcessor->importAccountMetadata($crmData);
}
/**
* @throws CloseException
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
$opportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$opportunities[] = $opportunitiesData['data'];
if ($opportunitiesData['has_more']) {
$this->logger->info('[Close] Sync Opportunities - count warning', [
'team_id' => $this->config->getTeam()->getId(),
'total' => $opportunitiesData['total'],
'count' => $opportunitiesData['count'],
'skip' => $opportunitiesData['skip'],
'strategies_count' => count($strategies),
]);
}
}
$opportunities = array_merge(...$opportunities);
} catch (CrmException $exception) {
$this->logger->error('Fetching opportunity data failed', [
'team' => $this->getTeam()->getSlug(),
'error' => $exception->getMessage(),
]);
return 0;
}
foreach ($opportunities as $opportunityMetadata) {
try {
$this->importOpportunity($opportunityMetadata);
$syncCount++;
} catch (Exception $exception) {
$this->logger->warning('Opportunity sync failed', [
'opportunity' => $opportunityMetadata->getId(),
'error' => $exception->getMessage(),
]);
}
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
$opportunity = $strategy->fetchOpportunities($parameters);
if (empty($opportunity['data'])) {
return null;
}
return $this->importOpportunity($opportunity['data']);
}
private function importOpportunity(OpportunityMetadata $crmData): ?Opportunity
{
if (! $crmData->getLeadId()) {
$this->logger->warning('Opportunity does not have a lead ID', [
'opportunity' => $crmData->getId(),
]);
return null;
}
$account = $this->getConfiguration()
->accounts()
->where('crm_provider_id', $crmData->getLeadId())
->first();
if ($account === null) {
$account = $this->accountProcessor->syncAccount($crmData->getLeadId());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$stage = $this->getConfiguration()
->stages()
->where('crm_provider_id', $crmData->getStageId())
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());
}
return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);
}
/**
* @param array<string,string> $crmData
* @param string[] $crmFields
*/
public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void
{
// handled in importOpportunity
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
/** No way to sync today.
$clContacts = $this->client->get('lead', [
'date_updated__gte' => $since->toDateString(),
'_order_by' => '-date_updated',
]);
foreach ($clContacts as $clContact) {
// Only sync if previously imported.
if ($this->hasContact($clContact['id'])) {
$this->importContact($clContact);
$syncCount++;
}
}
**/
} catch (Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$clContact = $this->client->get('contact/' . $crmId);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($clContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['lead_id']) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['lead_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['lead_id']);
}
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phones'] as $phoneNumber) {
if ($phoneNumber['type'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);
} else {
$parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();
$data = [
'account_id' => $account->id ?? null,
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['updated_by'],
'name' => $crmData['name'] ?? 'Unknown',
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => Carbon::parse($crmData['date_created']),
];
/** @var Contact */
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
public function syncOrganization(): void
{
$organisation = $this->getClient()->fetchOrganisation();
$this->metadataProcessor->syncOrganisation($organisation);
foreach ($organisation->getPipelines() as $pipelineMetadata) {
$this->metadataProcessor->syncPipeline($pipelineMetadata);
}
}
private function syncStandardFields(): void
{
// Currently we sync only opportunity fields
$stages = $this->getClient()->listStages();
foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
$this->config->save();
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$objectType = $this->convertObjectTypeToResource($fieldType);
$currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);
foreach ($currentFields as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
}
$this->config->save();
}
public function syncProfiles(?User $userToSearch = null): ?Profile
{
/*
* Fetch the profile of the user from the database
* Then fetch the user metadata from Close and update it
* In case there's no profile for the user, proceed with syncing all users
*/
$foundUser = null;
if ($userToSearch) {
$profile = $userToSearch->getProfile();
if ($profile instanceof Profile) {
$crmProviderId = $profile->getCrmProviderId();
if ($crmProviderId) {
$profileMetadata = $this->getClient()->fetchUser($crmProviderId);
if (! $profileMetadata instanceof ProfileMetadata) {
return null;
}
return $this->metadataProcessor->syncProfile($profileMetadata);
}
}
}
foreach ($this->getClient()->listUsers() as $userMetadata) {
$userProfile = $this->metadataProcessor->syncProfile($userMetadata);
if (
$userToSearch instanceof User
&& $userProfile instanceof Profile
&& $userProfile->getUserId() === $userToSearch->getId()
) {
$foundUser = $userProfile;
}
}
return $foundUser;
}
public function syncProfileFields(): void
{
// Not used.
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {
$data = [];
try {
// If search phrase resembles phone number remove special symbols
if (preg_match('/^([0-9\s\-\+\(\)]*)$/', $name)) {
$name = '+' . preg_replace('/[\s\-\+\(\)]/', '', $name);
}
// Close do not provide a unified way to search, so we must hack our own.
$objects = $this->client->get('lead', [
'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',
'_limit' => $count, '_skip' => $offset,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
// We need a contact to dial it.
if (empty($object['contacts'])) {
continue;
}
foreach ($object['contacts'] as $contact) {
$record = [
'crmId' => $contact['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),
'name' => $contact['name'],
'industry' => null,
'title' => $contact['title'],
'organization' => $object['display_name'],
'prospectType' => 'contact',
'phoneNumbers' => [],
];
foreach ($contact['phones'] as $phone) {
if ($phone['type'] === 'mobile') {
$number = $this->buildContactMobilePhone(null, $phone['phone']);
$record['phoneNumbers'][] = [
'number' => $number,
'nationalFormat' => phone_national(null, $number),
'type' => 'mobile',
];
} else {
$parsedNumber = $this->buildContactPhone(null, $phone['phone']);
// Add phone number to record.
if (empty($parsedNumber['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national(null, $parsedNumber['phone']),
'type' => 'phone',
];
}
}
}
$data[] = $record;
}
}
return $data;
});
return $data;
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'lead_id' => $account->crm_provider_id,
'_order_by' => '-date_updated',
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['_order_by'] = '-date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['_order_by'] = 'date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
$onlyOpen = false;
}
if ($onlyOpen) {
$params['status_type__in'] = 'active,won';
}
$clOpportunities = $this->client->get('opportunity', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($clOpportunities['data'] as $clOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $clOpportunity['status_id'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);
}
$record = [
'crmId' => $clOpportunity['id'],
'name' => $clOpportunity['note'],
'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),
'won' => $stage->probability === 100.00,
'closed' => $clOpportunity['status_type'] !== 'active',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$crmId = null;
if ($objectType === 'contact') {
$contact = $this->syncContact($objectId);
if ($contact && $contact->account_id) {
$crmId = $contact->account->crm_provider_id;
}
} else {
$crmId = $objectId;
}
if ($crmId) {
$clTasks = $this->client->get('task', [
'lead_id' => $crmId,
'_type' => 'lead',
'assigned_to' => $this->profile->crm_provider_id,
'is_complete' => 'false',
'_order_by' => 'date',
]);
foreach ($clTasks['data'] as $clTask) {
$data[] = [
'crmId' => $clTask['id'],
'subject' => $clTask['text'],
'due' => $clTask['date'] ?? null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(email(email:"' . $email . '"))',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['emails'] as $clEmail) {
if ($email === $clEmail['email']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
// Check if the user is internal.
$teamMember = $this->team->users()->where('phone', $phone)->exists();
// Skip the attendee if internal.
if ($teamMember === false) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(' . $phone . ')',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['phones'] as $clPhone) {
if ($phone === $clPhone['phone']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(name:"' . $name . '")',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
if ($clContact['name'] === $name || $clContact['display_name'] === $name) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : false;
}
}
}
return false;
});
return is_array($result) ? $result : null;
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
private function convertCrmData(string $crmId, ?int $userId = null): array
{
$lead = null;
$opportunity = null;
$account = null;
$stage = null;
$countryCode = null;
$contact = $this->syncContact($crmId);
if ($contact) {
$account = $contact->account;
if ($contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account) {
$countryCode = $account->country_code;
}
try {
$cpOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId,
);
if (! empty($cpOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception) {
// Nothing to see here.
}
}
return [
$lead,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
public function saveActivity(Activity $activity): Activity
{
switch ($activity->type) {
case Activity::TYPE_CONFERENCE:
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$activity = $this->buildCallPayload($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$activity = $this->buildTextMessagePayload($activity);
break;
}
return $activity;
}
private function mapStatus(string $status): string
{
switch ($status) {
case Activity::STATUS_COMPLETED:
case Activity::STATUS_IN_PROGRESS:
case Activity::STATUS_FAILED:
case Activity::STATUS_NO_ANSWER:
case Activity::STATUS_BUSY:
default:
return $status;
case Activity::STATUS_CANCELLED:
return 'cancel';
}
}
/**
* @throws CrmException
*/
private function buildCallPayload(Activity $activity): Activity
{
try {
if ($activity->crm_provider_id) {
// The activity should be logged under the existing Task (not Activity).
$data = [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $this->generateActivityDescription($activity),
'date' => $activity->getActualEndTime()->toDateString(),
'is_complete' => true,
];
$this->logger->info('[Close CRM] Updating task', [
'activity' => $activity->id,
'crm_id' => $activity->crm_provider_id,
'data' => $data,
]);
$this->client->put('task/' . $activity->crm_provider_id, $data);
} else {
// Just create an activity.
$data = [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',
'status' => $this->mapStatus($activity->getStatus()),
'note' => $this->generateActivityDescription($activity),
'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,
'phone' => $activity->to ? $activity->to->phone_number : null,
];
$clActivity = $this->client->post('activity/call', $data);
$this->logger->info('[Close CRM] Creating activity', [
'activity' => $activity->id,
'crm_id' => $clActivity['id'],
'data' => $data,
'response' => $clActivity,
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
}
} catch (ClientException $exception) {
$response = $exception->getResponse();
if ($response === null) {
// Trying to debug weird cases where this is null.
Sentry::captureException($exception);
}
$responseBody = $response->getBody();
$message = $responseBody;
$errorCode = $response->getStatusCode();
$jsonResponse = json_decode($responseBody, true);
if (isset($jsonResponse[0]['message'])) {
$message = $jsonResponse[0]['message'];
}
throw new CrmException($message, $errorCode);
}
return $activity;
}
private function buildTextMessagePayload(Activity $activity): Activity
{
$clActivity = $this->client->post('activity/sms', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',
'text' => $this->generateActivityDescription($activity),
'remote_phone' => $activity->to ? $activity->to->phone_number : null,
'local_phone' => $activity->to ? $activity->to->phone_number : null,
'source' => 'Close.io',
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->hasActivityType()) {
$description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;
}
if ($activity->hasTitle()) {
$description .= $activity->getTitle() . PHP_EOL;
}
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;
// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;
} elseif ($activity->canReviewActivity()) {
$playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;
}
if ($activity->type === Activity::TYPE_CONFERENCE) {
$description .= 'Attendees:'
. PHP_EOL
. (new FilterJoinedParticipants())->toString($activity);
}
if (\count($activity->notes) > 0) {
$description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;
foreach ($activity->notes as $note) {
$time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);
$description .= $time . ' ' . $note->note . PHP_EOL;
}
}
// Get all private messages.
$messages = $activity->messages()
->where('is_private', 1)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
// Get all public messages.
$messages = $activity->messages()
->where('is_private', 0)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
if ($activity->summary) {
$description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;
}
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$description = $activity->description;
break;
}
return $description;
}
public function saveFollowupActivity(Activity $activity, array $fields): ?string
{
// This is the user provided activity subject field.
if (empty($fields['name'])) {
return null;
}
$due = null;
if (empty($fields['due_date']) === false) {
$formatDue = Carbon::parse($fields['due_date']);
$due = $formatDue->toDateTimeString();
}
$clTask = $this->client->post('task', [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $fields['name'],
'date' => $due,
'is_complete' => false,
]);
// We don't actually create a corresponding activity object on our side yet.
return $clTask['id'];
}
/**
* Store transcripts as note.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
if ($activity->account_id === null) {
// We can only log to accounts (leads).
return;
}
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);
$clActivity = $this->client->post('activity/note', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'note' => $transcripts,
]);
// Store CRM Activity ID in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $clActivity['id'];
$transcription->save();
}
public function parseObjectType(string $objectId): string
{
if (Str::startsWith($objectId, 'lead')) {
return 'account';
}
if (Str::startsWith($objectId, 'cont')) {
return 'contact';
}
if (Str::startsWith($objectId, 'oppo')) {
return 'opportunity';
}
throw new InvalidArgumentException('Unsupported Object Type');
}
/**
* @inheritdoc
*/
public function updateStage($crmObject, Stage $stage): void
{
if ($crmObject instanceof Lead) {
// This would never get invoked since we merge lead/accounts in Close.
$this->client->put('lead/' . $crmObject->crm_provider_id, [
'status' => $stage->crm_provider_id,
]);
} else {
$this->client->put('opportunity/' . $crmObject->crm_provider_id, [
'status_id' => $stage->crm_provider_id,
]);
}
}
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);
}
public function prepareValueForUpdate(array $params): array
{
$convertedValue = $this->fieldValueConverter->convertToCrm(
$this->config,
$params['fieldName'],
$params['fieldValue'],
);
if ($this->isCustomField($params['fieldName'])) {
$params['fieldName'] = 'custom.' . $params['fieldName'];
}
$params['fieldValue'] = $convertedValue;
return parent::prepareValueForUpdate($params);
}
public function getRecord(string $objectType, string $objectId, array $fields = []): array
{
return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);
}
/**
*
* @throws UnexpectedValueException
*/
private function convertObjectTypeToResource(string $objectType): string
{
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
return 'opportunity';
case FieldData::OBJECT_CONTACT:
return 'contact';
case FieldData::OBJECT_ACCOUNT:
return 'lead';
case FieldData::OBJECT_TASK:
return 'activity';
default:
throw new UnexpectedValueException('Unsupported object type "' . $objectType . '"');
}
}
public function generateProviderUrl(string $providerId, string $objectType): ?string
{
$baseUrl = 'https://app.close.com/';
$url = null;
switch ($objectType) {
case 'account':
$url = $baseUrl . 'lead/' . $providerId;
break;
case 'contact':
$contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();
if ($contact && $contact->account_id) {
$url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;
}
break;
default:
// Sadly we can't deeplink to anything else in Close UI.
$url = null;
}
return $url;
}
/**
* Generate transcription for the activity.
*/
private function generateTranscription(Activity $activity): string
{
if (! $this->config->store_transcript) {
// If sending transcription to activity toggle is disabled
return '';
}
return $this->transcriptionService
->findTranscriptionByActivity($activity)
->map(static function (array $transcriptionSegment): string {
return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];
})
->implode(PHP_EOL);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {
try {
$client = $this->getClient();
$task = $client->get('task/' . $crmProviderId);
return ! empty($task);
} catch (HttpNotFoundException) {
// Task not found in CRM - this is expected and permanent
$this->logger->info('[Close] Task not found during verification', [
'task_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
...
|
55239
|
NULL
|
NULL
|
NULL
|
|
55241
|
1914
|
11
|
2026-05-18T13:58:40.223318+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112720223_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Close;
use Cache;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CloseInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Exceptions\UnexpectedCallException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Close\Processor\AccountProcessor;
use Jiminny\Services\Crm\Close\Processor\MetadataProcessor;
use Jiminny\Services\Crm\Close\Processor\OpportunityProcessor;
use Jiminny\Services\Crm\Close\Processor\StageProcessor;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\Metadata\OpportunityMetadata;
use Jiminny\Services\Crm\Metadata\ProfileMetadata;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Sentry;
use UnexpectedValueException;
class Service extends BaseService implements
CloseInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private StandardFieldMetadata $standardFieldMetadata;
private MetadataProcessor $metadataProcessor;
private FieldValueConverter $fieldValueConverter;
private StageProcessor $stageProcessor;
private OpportunityProcessor $opportunityProcessor;
private AccountProcessor $accountProcessor;
public function __construct(
Client $client,
StandardFieldMetadata $standardFieldMetadata,
MetadataProcessor $metadataProcessor,
FieldValueConverter $fieldValueConverter,
StageProcessor $stageResolver,
OpportunityProcessor $opportunityProcessor,
AccountProcessor $accountProcessor,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->standardFieldMetadata = $standardFieldMetadata;
$this->metadataProcessor = $metadataProcessor;
$this->fieldValueConverter = $fieldValueConverter;
$this->stageProcessor = $stageResolver;
$this->opportunityProcessor = $opportunityProcessor;
$this->accountProcessor = $accountProcessor;
}
public function getDisplayName(): string
{
return 'Close';
}
public function setConfiguration(Configuration $config): void
{
parent::setConfiguration($config);
$this->metadataProcessor->setConfiguration($config);
$this->stageProcessor->setConfiguration($config);
$this->opportunityProcessor->setConfiguration($config);
$this->accountProcessor->setConfiguration($config);
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);
}
private function getClient(): Client
{
if (! $this->client instanceof Client) {
throw new UnexpectedCallException('Client not set');
}
return $this->client;
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_OPPORTUNITY,
parent::OBJECT_CONTACT,
parent::OBJECT_ACCOUNT,
];
}
protected function getFields(string $crmObject): array
{
// not used
return [];
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Set up the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
/**
* @important Works only for custom fields
*/
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->getObjectType());
// We can only sync custom fields in this CRM.
if ($this->isCustomField($field->getCrmProviderId()) === false) {
return;
}
$crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());
$this->metadataProcessor->syncField($crmField);
}
private function isCustomField(string $fieldId): bool
{
return strpos($fieldId, 'cf_') === 0;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
// handled in syncFields()
return [];
}
/**
* @important We only support stages on the opportunity object
*
* @param string[]|null $types
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if (! $missingStageName) {
// This is taken care of by syncOrganization()
return null;
}
$stage = $this->stageProcessor->resolveFromStageId($missingStageName);
if ($stage instanceof Stage) {
return $stage;
}
$stageMetadata = $this->getClient()->fetchStage($missingStageName);
if (! $stageMetadata) {
$this->logger->error('Stage does not exist', [
'stage' => $missingStageName,
]);
return null;
}
return $this->stageProcessor->importStage($stageMetadata);
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Even though Close.io has the concept of "leads", they fit more into our concept of accounts.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Not a supported entity.
return null;
}
/**
* @throws Exception
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
foreach ($this->getClient()->listAccounts($since) as $clAccount) {
// Only sync if previously imported.
if ($this->hasAccount($clAccount->getId())) {
$this->importAccount($clAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
$this->logger->error('Account sync failed', [
'error' => $exception->getMessage(),
]);
throw $exception;
}
return $syncCount;
}
public function syncAccount(string $crmId): ?Account
{
return $this->accountProcessor->syncAccount($crmId);
}
private function importAccount($crmData): Account
{
return $this->accountProcessor->importAccountMetadata($crmData);
}
/**
* @throws CloseException
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
$opportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$opportunities[] = $opportunitiesData['data'];
if ($opportunitiesData['has_more']) {
$this->logger->info('[Close] Sync Opportunities - count warning', [
'team_id' => $this->config->getTeam()->getId(),
'total' => $opportunitiesData['total'],
'count' => $opportunitiesData['count'],
'skip' => $opportunitiesData['skip'],
'strategies_count' => count($strategies),
]);
}
}
$opportunities = array_merge(...$opportunities);
} catch (CrmException $exception) {
$this->logger->error('Fetching opportunity data failed', [
'team' => $this->getTeam()->getSlug(),
'error' => $exception->getMessage(),
]);
return 0;
}
foreach ($opportunities as $opportunityMetadata) {
try {
$this->importOpportunity($opportunityMetadata);
$syncCount++;
} catch (Exception $exception) {
$this->logger->warning('Opportunity sync failed', [
'opportunity' => $opportunityMetadata->getId(),
'error' => $exception->getMessage(),
]);
}
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
$opportunity = $strategy->fetchOpportunities($parameters);
if (empty($opportunity['data'])) {
return null;
}
return $this->importOpportunity($opportunity['data']);
}
private function importOpportunity(OpportunityMetadata $crmData): ?Opportunity
{
if (! $crmData->getLeadId()) {
$this->logger->warning('Opportunity does not have a lead ID', [
'opportunity' => $crmData->getId(),
]);
return null;
}
$account = $this->getConfiguration()
->accounts()
->where('crm_provider_id', $crmData->getLeadId())
->first();
if ($account === null) {
$account = $this->accountProcessor->syncAccount($crmData->getLeadId());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$stage = $this->getConfiguration()
->stages()
->where('crm_provider_id', $crmData->getStageId())
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());
}
return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);
}
/**
* @param array<string,string> $crmData
* @param string[] $crmFields
*/
public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void
{
// handled in importOpportunity
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
/** No way to sync today.
$clContacts = $this->client->get('lead', [
'date_updated__gte' => $since->toDateString(),
'_order_by' => '-date_updated',
]);
foreach ($clContacts as $clContact) {
// Only sync if previously imported.
if ($this->hasContact($clContact['id'])) {
$this->importContact($clContact);
$syncCount++;
}
}
**/
} catch (Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$clContact = $this->client->get('contact/' . $crmId);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($clContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['lead_id']) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['lead_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['lead_id']);
}
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phones'] as $phoneNumber) {
if ($phoneNumber['type'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);
} else {
$parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();
$data = [
'account_id' => $account->id ?? null,
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['updated_by'],
'name' => $crmData['name'] ?? 'Unknown',
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => Carbon::parse($crmData['date_created']),
];
/** @var Contact */
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
public function syncOrganization(): void
{
$organisation = $this->getClient()->fetchOrganisation();
$this->metadataProcessor->syncOrganisation($organisation);
foreach ($organisation->getPipelines() as $pipelineMetadata) {
$this->metadataProcessor->syncPipeline($pipelineMetadata);
}
}
private function syncStandardFields(): void
{
// Currently we sync only opportunity fields
$stages = $this->getClient()->listStages();
foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
$this->config->save();
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$objectType = $this->convertObjectTypeToResource($fieldType);
$currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);
foreach ($currentFields as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
}
$this->config->save();
}
public function syncProfiles(?User $userToSearch = null): ?Profile
{
/*
* Fetch the profile of the user from the database
* Then fetch the user metadata from Close and update it
* In case there's no profile for the user, proceed with syncing all users
*/
$foundUser = null;
if ($userToSearch) {
$profile = $userToSearch->getProfile();
if ($profile instanceof Profile) {
$crmProviderId = $profile->getCrmProviderId();
if ($crmProviderId) {
$profileMetadata = $this->getClient()->fetchUser($crmProviderId);
if (! $profileMetadata instanceof ProfileMetadata) {
return null;
}
return $this->metadataProcessor->syncProfile($profileMetadata);
}
}
}
foreach ($this->getClient()->listUsers() as $userMetadata) {
$userProfile = $this->metadataProcessor->syncProfile($userMetadata);
if (
$userToSearch instanceof User
&& $userProfile instanceof Profile
&& $userProfile->getUserId() === $userToSearch->getId()
) {
$foundUser = $userProfile;
}
}
return $foundUser;
}
public function syncProfileFields(): void
{
// Not used.
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {
$data = [];
try {
// If search phrase resembles phone number remove special symbols
if (preg_match('/^([0-9\s\-\+\(\)]*)$/', $name)) {
$name = '+' . preg_replace('/[\s\-\+\(\)]/', '', $name);
}
// Close do not provide a unified way to search, so we must hack our own.
$objects = $this->client->get('lead', [
'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',
'_limit' => $count, '_skip' => $offset,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
// We need a contact to dial it.
if (empty($object['contacts'])) {
continue;
}
foreach ($object['contacts'] as $contact) {
$record = [
'crmId' => $contact['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),
'name' => $contact['name'],
'industry' => null,
'title' => $contact['title'],
'organization' => $object['display_name'],
'prospectType' => 'contact',
'phoneNumbers' => [],
];
foreach ($contact['phones'] as $phone) {
if ($phone['type'] === 'mobile') {
$number = $this->buildContactMobilePhone(null, $phone['phone']);
$record['phoneNumbers'][] = [
'number' => $number,
'nationalFormat' => phone_national(null, $number),
'type' => 'mobile',
];
} else {
$parsedNumber = $this->buildContactPhone(null, $phone['phone']);
// Add phone number to record.
if (empty($parsedNumber['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national(null, $parsedNumber['phone']),
'type' => 'phone',
];
}
}
}
$data[] = $record;
}
}
return $data;
});
return $data;
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'lead_id' => $account->crm_provider_id,
'_order_by' => '-date_updated',
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['_order_by'] = '-date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['_order_by'] = 'date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
$onlyOpen = false;
}
if ($onlyOpen) {
$params['status_type__in'] = 'active,won';
}
$clOpportunities = $this->client->get('opportunity', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($clOpportunities['data'] as $clOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $clOpportunity['status_id'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);
}
$record = [
'crmId' => $clOpportunity['id'],
'name' => $clOpportunity['note'],
'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),
'won' => $stage->probability === 100.00,
'closed' => $clOpportunity['status_type'] !== 'active',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$crmId = null;
if ($objectType === 'contact') {
$contact = $this->syncContact($objectId);
if ($contact && $contact->account_id) {
$crmId = $contact->account->crm_provider_id;
}
} else {
$crmId = $objectId;
}
if ($crmId) {
$clTasks = $this->client->get('task', [
'lead_id' => $crmId,
'_type' => 'lead',
'assigned_to' => $this->profile->crm_provider_id,
'is_complete' => 'false',
'_order_by' => 'date',
]);
foreach ($clTasks['data'] as $clTask) {
$data[] = [
'crmId' => $clTask['id'],
'subject' => $clTask['text'],
'due' => $clTask['date'] ?? null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(email(email:"' . $email . '"))',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['emails'] as $clEmail) {
if ($email === $clEmail['email']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
// Check if the user is internal.
$teamMember = $this->team->users()->where('phone', $phone)->exists();
// Skip the attendee if internal.
if ($teamMember === false) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(' . $phone . ')',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['phones'] as $clPhone) {
if ($phone === $clPhone['phone']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(name:"' . $name . '")',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
if ($clContact['name'] === $name || $clContact['display_name'] === $name) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : false;
}
}
}
return false;
});
return is_array($result) ? $result : null;
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
private function convertCrmData(string $crmId, ?int $userId = null): array
{
$lead = null;
$opportunity = null;
$account = null;
$stage = null;
$countryCode = null;
$contact = $this->syncContact($crmId);
if ($contact) {
$account = $contact->account;
if ($contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account) {
$countryCode = $account->country_code;
}
try {
$cpOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId,
);
if (! empty($cpOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception) {
// Nothing to see here.
}
}
return [
$lead,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
public function saveActivity(Activity $activity): Activity
{
switch ($activity->type) {
case Activity::TYPE_CONFERENCE:
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$activity = $this->buildCallPayload($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$activity = $this->buildTextMessagePayload($activity);
break;
}
return $activity;
}
private function mapStatus(string $status): string
{
switch ($status) {
case Activity::STATUS_COMPLETED:
case Activity::STATUS_IN_PROGRESS:
case Activity::STATUS_FAILED:
case Activity::STATUS_NO_ANSWER:
case Activity::STATUS_BUSY:
default:
return $status;
case Activity::STATUS_CANCELLED:
return 'cancel';
}
}
/**
* @throws CrmException
*/
private function buildCallPayload(Activity $activity): Activity
{
try {
if ($activity->crm_provider_id) {
// The activity should be logged under the existing Task (not Activity).
$data = [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $this->generateActivityDescription($activity),
'date' => $activity->getActualEndTime()->toDateString(),
'is_complete' => true,
];
$this->logger->info('[Close CRM] Updating task', [
'activity' => $activity->id,
'crm_id' => $activity->crm_provider_id,
'data' => $data,
]);
$this->client->put('task/' . $activity->crm_provider_id, $data);
} else {
// Just create an activity.
$data = [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',
'status' => $this->mapStatus($activity->getStatus()),
'note' => $this->generateActivityDescription($activity),
'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,
'phone' => $activity->to ? $activity->to->phone_number : null,
];
$clActivity = $this->client->post('activity/call', $data);
$this->logger->info('[Close CRM] Creating activity', [
'activity' => $activity->id,
'crm_id' => $clActivity['id'],
'data' => $data,
'response' => $clActivity,
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
}
} catch (ClientException $exception) {
$response = $exception->getResponse();
if ($response === null) {
// Trying to debug weird cases where this is null.
Sentry::captureException($exception);
}
$responseBody = $response->getBody();
$message = $responseBody;
$errorCode = $response->getStatusCode();
$jsonResponse = json_decode($responseBody, true);
if (isset($jsonResponse[0]['message'])) {
$message = $jsonResponse[0]['message'];
}
throw new CrmException($message, $errorCode);
}
return $activity;
}
private function buildTextMessagePayload(Activity $activity): Activity
{
$clActivity = $this->client->post('activity/sms', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',
'text' => $this->generateActivityDescription($activity),
'remote_phone' => $activity->to ? $activity->to->phone_number : null,
'local_phone' => $activity->to ? $activity->to->phone_number : null,
'source' => 'Close.io',
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->hasActivityType()) {
$description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;
}
if ($activity->hasTitle()) {
$description .= $activity->getTitle() . PHP_EOL;
}
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;
// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;
} elseif ($activity->canReviewActivity()) {
$playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;
}
if ($activity->type === Activity::TYPE_CONFERENCE) {
$description .= 'Attendees:'
. PHP_EOL
. (new FilterJoinedParticipants())->toString($activity);
}
if (\count($activity->notes) > 0) {
$description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;
foreach ($activity->notes as $note) {
$time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);
$description .= $time . ' ' . $note->note . PHP_EOL;
}
}
// Get all private messages.
$messages = $activity->messages()
->where('is_private', 1)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
// Get all public messages.
$messages = $activity->messages()
->where('is_private', 0)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
if ($activity->summary) {
$description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;
}
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$description = $activity->description;
break;
}
return $description;
}
public function saveFollowupActivity(Activity $activity, array $fields): ?string
{
// This is the user provided activity subject field.
if (empty($fields['name'])) {
return null;
}
$due = null;
if (empty($fields['due_date']) === false) {
$formatDue = Carbon::parse($fields['due_date']);
$due = $formatDue->toDateTimeString();
}
$clTask = $this->client->post('task', [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $fields['name'],
'date' => $due,
'is_complete' => false,
]);
// We don't actually create a corresponding activity object on our side yet.
return $clTask['id'];
}
/**
* Store transcripts as note.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
if ($activity->account_id === null) {
// We can only log to accounts (leads).
return;
}
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);
$clActivity = $this->client->post('activity/note', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'note' => $transcripts,
]);
// Store CRM Activity ID in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $clActivity['id'];
$transcription->save();
}
public function parseObjectType(string $objectId): string
{
if (Str::startsWith($objectId, 'lead')) {
return 'account';
}
if (Str::startsWith($objectId, 'cont')) {
return 'contact';
}
if (Str::startsWith($objectId, 'oppo')) {
return 'opportunity';
}
throw new InvalidArgumentException('Unsupported Object Type');
}
/**
* @inheritdoc
*/
public function updateStage($crmObject, Stage $stage): void
{
if ($crmObject instanceof Lead) {
// This would never get invoked since we merge lead/accounts in Close.
$this->client->put('lead/' . $crmObject->crm_provider_id, [
'status' => $stage->crm_provider_id,
]);
} else {
$this->client->put('opportunity/' . $crmObject->crm_provider_id, [
'status_id' => $stage->crm_provider_id,
]);
}
}
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);
}
public function prepareValueForUpdate(array $params): array
{
$convertedValue = $this->fieldValueConverter->convertToCrm(
$this->config,
$params['fieldName'],
$params['fieldValue'],
);
if ($this->isCustomField($params['fieldName'])) {
$params['fieldName'] = 'custom.' . $params['fieldName'];
}
$params['fieldValue'] = $convertedValue;
return parent::prepareValueForUpdate($params);
}
public function getRecord(string $objectType, string $objectId, array $fields = []): array
{
return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);
}
/**
*
* @throws UnexpectedValueException
*/
private function convertObjectTypeToResource(string $objectType): string
{
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
return 'opportunity';
case FieldData::OBJECT_CONTACT:
return 'contact';
case FieldData::OBJECT_ACCOUNT:
return 'lead';
case FieldData::OBJECT_TASK:
return 'activity';
default:
throw new UnexpectedValueException('Unsupported object type "' . $objectType . '"');
}
}
public function generateProviderUrl(string $providerId, string $objectType): ?string
{
$baseUrl = 'https://app.close.com/';
$url = null;
switch ($objectType) {
case 'account':
$url = $baseUrl . 'lead/' . $providerId;
break;
case 'contact':
$contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();
if ($contact && $contact->account_id) {
$url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;
}
break;
default:
// Sadly we can't deeplink to anything else in Close UI.
$url = null;
}
return $url;
}
/**
* Generate transcription for the activity.
*/
private function generateTranscription(Activity $activity): string
{
if (! $this->config->store_transcript) {
// If sending transcription to activity toggle is disabled
return '';
}
return $this->transcriptionService
->findTranscriptionByActivity($activity)
->map(static function (array $transcriptionSegment): string {
return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];
})
->implode(PHP_EOL);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {
try {
$client = $this->getClient();
$task = $client->get('task/' . $crmProviderId);
return ! empty($task);
} catch (HttpNotFoundException) {
// Task not found in CRM - this is expected and permanent
$this->logger->info('[Close] Task not found during verification', [
'task_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
...
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<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,"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":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"39","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"5","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\\Services\\Crm\\Close;\n\nuse Cache;\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Str;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CloseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Exceptions\\UnexpectedCallException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\AccountProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\MetadataProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\OpportunityProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\StageProcessor;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\Metadata\\OpportunityMetadata;\nuse Jiminny\\Services\\Crm\\Metadata\\ProfileMetadata;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Sentry;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n CloseInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n VerifyTaskExistsInterface\n{\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private StandardFieldMetadata $standardFieldMetadata;\n private MetadataProcessor $metadataProcessor;\n private FieldValueConverter $fieldValueConverter;\n private StageProcessor $stageProcessor;\n private OpportunityProcessor $opportunityProcessor;\n private AccountProcessor $accountProcessor;\n\n public function __construct(\n Client $client,\n StandardFieldMetadata $standardFieldMetadata,\n MetadataProcessor $metadataProcessor,\n FieldValueConverter $fieldValueConverter,\n StageProcessor $stageResolver,\n OpportunityProcessor $opportunityProcessor,\n AccountProcessor $accountProcessor,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->standardFieldMetadata = $standardFieldMetadata;\n $this->metadataProcessor = $metadataProcessor;\n $this->fieldValueConverter = $fieldValueConverter;\n $this->stageProcessor = $stageResolver;\n $this->opportunityProcessor = $opportunityProcessor;\n $this->accountProcessor = $accountProcessor;\n }\n\n public function getDisplayName(): string\n {\n return 'Close';\n }\n\n public function setConfiguration(Configuration $config): void\n {\n parent::setConfiguration($config);\n\n $this->metadataProcessor->setConfiguration($config);\n $this->stageProcessor->setConfiguration($config);\n $this->opportunityProcessor->setConfiguration($config);\n $this->accountProcessor->setConfiguration($config);\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);\n }\n\n private function getClient(): Client\n {\n if (! $this->client instanceof Client) {\n throw new UnexpectedCallException('Client not set');\n }\n\n return $this->client;\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_OPPORTUNITY,\n parent::OBJECT_CONTACT,\n parent::OBJECT_ACCOUNT,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n // not used\n return [];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Set up the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n /**\n * @important Works only for custom fields\n */\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->getObjectType());\n\n // We can only sync custom fields in this CRM.\n if ($this->isCustomField($field->getCrmProviderId()) === false) {\n return;\n }\n\n $crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());\n\n $this->metadataProcessor->syncField($crmField);\n }\n\n private function isCustomField(string $fieldId): bool\n {\n return strpos($fieldId, 'cf_') === 0;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n // handled in syncFields()\n return [];\n }\n\n /**\n * @important We only support stages on the opportunity object\n *\n * @param string[]|null $types\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if (! $missingStageName) {\n // This is taken care of by syncOrganization()\n return null;\n }\n\n $stage = $this->stageProcessor->resolveFromStageId($missingStageName);\n\n if ($stage instanceof Stage) {\n return $stage;\n }\n\n $stageMetadata = $this->getClient()->fetchStage($missingStageName);\n\n if (! $stageMetadata) {\n $this->logger->error('Stage does not exist', [\n 'stage' => $missingStageName,\n ]);\n\n return null;\n }\n\n\n return $this->stageProcessor->importStage($stageMetadata);\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Even though Close.io has the concept of \"leads\", they fit more into our concept of accounts.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Not a supported entity.\n return null;\n }\n\n /**\n * @throws Exception\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n foreach ($this->getClient()->listAccounts($since) as $clAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($clAccount->getId())) {\n $this->importAccount($clAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n $this->logger->error('Account sync failed', [\n 'error' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n return $syncCount;\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n return $this->accountProcessor->syncAccount($crmId);\n }\n\n private function importAccount($crmData): Account\n {\n return $this->accountProcessor->importAccountMetadata($crmData);\n }\n\n /**\n * @throws CloseException\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n $opportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $opportunities[] = $opportunitiesData['data'];\n\n if ($opportunitiesData['has_more']) {\n $this->logger->info('[Close] Sync Opportunities - count warning', [\n 'team_id' => $this->config->getTeam()->getId(),\n 'total' => $opportunitiesData['total'],\n 'count' => $opportunitiesData['count'],\n 'skip' => $opportunitiesData['skip'],\n 'strategies_count' => count($strategies),\n ]);\n }\n }\n\n $opportunities = array_merge(...$opportunities);\n } catch (CrmException $exception) {\n $this->logger->error('Fetching opportunity data failed', [\n 'team' => $this->getTeam()->getSlug(),\n 'error' => $exception->getMessage(),\n ]);\n\n return 0;\n }\n\n foreach ($opportunities as $opportunityMetadata) {\n try {\n $this->importOpportunity($opportunityMetadata);\n $syncCount++;\n } catch (Exception $exception) {\n $this->logger->warning('Opportunity sync failed', [\n 'opportunity' => $opportunityMetadata->getId(),\n 'error' => $exception->getMessage(),\n ]);\n }\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n $opportunity = $strategy->fetchOpportunities($parameters);\n\n if (empty($opportunity['data'])) {\n return null;\n }\n\n return $this->importOpportunity($opportunity['data']);\n }\n\n private function importOpportunity(OpportunityMetadata $crmData): ?Opportunity\n {\n if (! $crmData->getLeadId()) {\n $this->logger->warning('Opportunity does not have a lead ID', [\n 'opportunity' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $account = $this->getConfiguration()\n ->accounts()\n ->where('crm_provider_id', $crmData->getLeadId())\n ->first();\n\n if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $stage = $this->getConfiguration()\n ->stages()\n ->where('crm_provider_id', $crmData->getStageId())\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());\n }\n\n return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);\n }\n\n /**\n * @param array<string,string> $crmData\n * @param string[] $crmFields\n */\n public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void\n {\n // handled in importOpportunity\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n /** No way to sync today.\n $clContacts = $this->client->get('lead', [\n 'date_updated__gte' => $since->toDateString(),\n '_order_by' => '-date_updated',\n ]);\n\n foreach ($clContacts as $clContact) {\n // Only sync if previously imported.\n if ($this->hasContact($clContact['id'])) {\n $this->importContact($clContact);\n $syncCount++;\n }\n }\n **/\n } catch (Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $clContact = $this->client->get('contact/' . $crmId);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($clContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['lead_id']) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['lead_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['lead_id']);\n }\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phones'] as $phoneNumber) {\n if ($phoneNumber['type'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();\n\n $data = [\n 'account_id' => $account->id ?? null,\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['updated_by'],\n 'name' => $crmData['name'] ?? 'Unknown',\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => Carbon::parse($crmData['date_created']),\n ];\n\n /** @var Contact */\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n public function syncOrganization(): void\n {\n $organisation = $this->getClient()->fetchOrganisation();\n\n $this->metadataProcessor->syncOrganisation($organisation);\n\n foreach ($organisation->getPipelines() as $pipelineMetadata) {\n $this->metadataProcessor->syncPipeline($pipelineMetadata);\n }\n }\n\n private function syncStandardFields(): void\n {\n // Currently we sync only opportunity fields\n $stages = $this->getClient()->listStages();\n foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n\n $this->config->save();\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $objectType = $this->convertObjectTypeToResource($fieldType);\n $currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);\n\n foreach ($currentFields as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n }\n\n $this->config->save();\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n /*\n * Fetch the profile of the user from the database\n * Then fetch the user metadata from Close and update it\n * In case there's no profile for the user, proceed with syncing all users\n */\n $foundUser = null;\n\n if ($userToSearch) {\n $profile = $userToSearch->getProfile();\n\n if ($profile instanceof Profile) {\n $crmProviderId = $profile->getCrmProviderId();\n\n if ($crmProviderId) {\n $profileMetadata = $this->getClient()->fetchUser($crmProviderId);\n\n if (! $profileMetadata instanceof ProfileMetadata) {\n return null;\n }\n\n return $this->metadataProcessor->syncProfile($profileMetadata);\n }\n }\n }\n\n foreach ($this->getClient()->listUsers() as $userMetadata) {\n $userProfile = $this->metadataProcessor->syncProfile($userMetadata);\n\n if (\n $userToSearch instanceof User\n && $userProfile instanceof Profile\n && $userProfile->getUserId() === $userToSearch->getId()\n ) {\n $foundUser = $userProfile;\n }\n }\n\n return $foundUser;\n }\n\n public function syncProfileFields(): void\n {\n // Not used.\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {\n $data = [];\n\n try {\n // If search phrase resembles phone number remove special symbols\n if (preg_match('/^([0-9\\s\\-\\+\\(\\)]*)$/', $name)) {\n $name = '+' . preg_replace('/[\\s\\-\\+\\(\\)]/', '', $name);\n }\n\n // Close do not provide a unified way to search, so we must hack our own.\n $objects = $this->client->get('lead', [\n 'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',\n '_limit' => $count, '_skip' => $offset,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n // We need a contact to dial it.\n if (empty($object['contacts'])) {\n continue;\n }\n\n foreach ($object['contacts'] as $contact) {\n $record = [\n 'crmId' => $contact['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),\n 'name' => $contact['name'],\n 'industry' => null,\n 'title' => $contact['title'],\n 'organization' => $object['display_name'],\n 'prospectType' => 'contact',\n 'phoneNumbers' => [],\n ];\n\n foreach ($contact['phones'] as $phone) {\n if ($phone['type'] === 'mobile') {\n $number = $this->buildContactMobilePhone(null, $phone['phone']);\n\n $record['phoneNumbers'][] = [\n 'number' => $number,\n 'nationalFormat' => phone_national(null, $number),\n 'type' => 'mobile',\n ];\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phone['phone']);\n\n // Add phone number to record.\n if (empty($parsedNumber['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national(null, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n }\n }\n\n $data[] = $record;\n }\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'lead_id' => $account->crm_provider_id,\n '_order_by' => '-date_updated',\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['_order_by'] = '-date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['_order_by'] = 'date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $params['status_type__in'] = 'active,won';\n }\n\n $clOpportunities = $this->client->get('opportunity', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($clOpportunities['data'] as $clOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $clOpportunity['status_id'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);\n }\n\n $record = [\n 'crmId' => $clOpportunity['id'],\n 'name' => $clOpportunity['note'],\n 'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),\n 'won' => $stage->probability === 100.00,\n 'closed' => $clOpportunity['status_type'] !== 'active',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $crmId = null;\n\n if ($objectType === 'contact') {\n $contact = $this->syncContact($objectId);\n\n if ($contact && $contact->account_id) {\n $crmId = $contact->account->crm_provider_id;\n }\n } else {\n $crmId = $objectId;\n }\n\n if ($crmId) {\n $clTasks = $this->client->get('task', [\n 'lead_id' => $crmId,\n '_type' => 'lead',\n 'assigned_to' => $this->profile->crm_provider_id,\n 'is_complete' => 'false',\n '_order_by' => 'date',\n ]);\n\n foreach ($clTasks['data'] as $clTask) {\n $data[] = [\n 'crmId' => $clTask['id'],\n 'subject' => $clTask['text'],\n 'due' => $clTask['date'] ?? null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(email(email:\"' . $email . '\"))',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['emails'] as $clEmail) {\n if ($email === $clEmail['email']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(' . $phone . ')',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['phones'] as $clPhone) {\n if ($phone === $clPhone['phone']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(name:\"' . $name . '\")',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n if ($clContact['name'] === $name || $clContact['display_name'] === $name) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n }\n }\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n private function convertCrmData(string $crmId, ?int $userId = null): array\n {\n $lead = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $contact = $this->syncContact($crmId);\n if ($contact) {\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case Activity::STATUS_COMPLETED:\n case Activity::STATUS_IN_PROGRESS:\n case Activity::STATUS_FAILED:\n case Activity::STATUS_NO_ANSWER:\n case Activity::STATUS_BUSY:\n default:\n return $status;\n case Activity::STATUS_CANCELLED:\n return 'cancel';\n }\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $data = [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $this->generateActivityDescription($activity),\n 'date' => $activity->getActualEndTime()->toDateString(),\n 'is_complete' => true,\n ];\n\n $this->logger->info('[Close CRM] Updating task', [\n 'activity' => $activity->id,\n 'crm_id' => $activity->crm_provider_id,\n 'data' => $data,\n ]);\n\n $this->client->put('task/' . $activity->crm_provider_id, $data);\n } else {\n // Just create an activity.\n $data = [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',\n 'status' => $this->mapStatus($activity->getStatus()),\n 'note' => $this->generateActivityDescription($activity),\n 'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,\n 'phone' => $activity->to ? $activity->to->phone_number : null,\n ];\n\n $clActivity = $this->client->post('activity/call', $data);\n\n $this->logger->info('[Close CRM] Creating activity', [\n 'activity' => $activity->id,\n 'crm_id' => $clActivity['id'],\n 'data' => $data,\n 'response' => $clActivity,\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n $response = $exception->getResponse();\n\n if ($response === null) {\n // Trying to debug weird cases where this is null.\n Sentry::captureException($exception);\n }\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $clActivity = $this->client->post('activity/sms', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',\n 'text' => $this->generateActivityDescription($activity),\n 'remote_phone' => $activity->to ? $activity->to->phone_number : null,\n 'local_phone' => $activity->to ? $activity->to->phone_number : null,\n 'source' => 'Close.io',\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n if ($activity->hasActivityType()) {\n $description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;\n }\n if ($activity->hasTitle()) {\n $description .= $activity->getTitle() . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['name'])) {\n return null;\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::parse($fields['due_date']);\n $due = $formatDue->toDateTimeString();\n }\n\n $clTask = $this->client->post('task', [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $fields['name'],\n 'date' => $due,\n 'is_complete' => false,\n ]);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $clTask['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n if ($activity->account_id === null) {\n // We can only log to accounts (leads).\n return;\n }\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $clActivity = $this->client->post('activity/note', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'note' => $transcripts,\n ]);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $clActivity['id'];\n $transcription->save();\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, 'lead')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, 'cont')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, 'oppo')) {\n return 'opportunity';\n }\n\n throw new InvalidArgumentException('Unsupported Object Type');\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n // This would never get invoked since we merge lead/accounts in Close.\n $this->client->put('lead/' . $crmObject->crm_provider_id, [\n 'status' => $stage->crm_provider_id,\n ]);\n } else {\n $this->client->put('opportunity/' . $crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n $convertedValue = $this->fieldValueConverter->convertToCrm(\n $this->config,\n $params['fieldName'],\n $params['fieldValue'],\n );\n\n if ($this->isCustomField($params['fieldName'])) {\n $params['fieldName'] = 'custom.' . $params['fieldName'];\n }\n\n $params['fieldValue'] = $convertedValue;\n\n return parent::prepareValueForUpdate($params);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);\n }\n\n /**\n *\n * @throws UnexpectedValueException\n */\n private function convertObjectTypeToResource(string $objectType): string\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return 'opportunity';\n\n case FieldData::OBJECT_CONTACT:\n return 'contact';\n\n case FieldData::OBJECT_ACCOUNT:\n return 'lead';\n\n case FieldData::OBJECT_TASK:\n return 'activity';\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.close.com/';\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'lead/' . $providerId;\n\n break;\n\n case 'contact':\n $contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();\n if ($contact && $contact->account_id) {\n $url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;\n }\n\n break;\n\n default:\n // Sadly we can't deeplink to anything else in Close UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $client = $this->getClient();\n $task = $client->get('task/' . $crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Close] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (CloseException $e) {\n // Handle 404 responses from Close API\n if ($e->getResponseStatusCode() === 404) {\n $this->logger->info('[Close] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Close exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Close;\n\nuse Cache;\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\ClientException;\nuse Illuminate\\Support\\Str;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\CloseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Exceptions\\UnexpectedCallException;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\AccountProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\MetadataProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\OpportunityProcessor;\nuse Jiminny\\Services\\Crm\\Close\\Processor\\StageProcessor;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Services\\Crm\\Metadata\\OpportunityMetadata;\nuse Jiminny\\Services\\Crm\\Metadata\\ProfileMetadata;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Sentry;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n CloseInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n VerifyTaskExistsInterface\n{\n private const int NOTE_BODY_MAX_LENGTH = 3000000;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n private StandardFieldMetadata $standardFieldMetadata;\n private MetadataProcessor $metadataProcessor;\n private FieldValueConverter $fieldValueConverter;\n private StageProcessor $stageProcessor;\n private OpportunityProcessor $opportunityProcessor;\n private AccountProcessor $accountProcessor;\n\n public function __construct(\n Client $client,\n StandardFieldMetadata $standardFieldMetadata,\n MetadataProcessor $metadataProcessor,\n FieldValueConverter $fieldValueConverter,\n StageProcessor $stageResolver,\n OpportunityProcessor $opportunityProcessor,\n AccountProcessor $accountProcessor,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->standardFieldMetadata = $standardFieldMetadata;\n $this->metadataProcessor = $metadataProcessor;\n $this->fieldValueConverter = $fieldValueConverter;\n $this->stageProcessor = $stageResolver;\n $this->opportunityProcessor = $opportunityProcessor;\n $this->accountProcessor = $accountProcessor;\n }\n\n public function getDisplayName(): string\n {\n return 'Close';\n }\n\n public function setConfiguration(Configuration $config): void\n {\n parent::setConfiguration($config);\n\n $this->metadataProcessor->setConfiguration($config);\n $this->stageProcessor->setConfiguration($config);\n $this->opportunityProcessor->setConfiguration($config);\n $this->accountProcessor->setConfiguration($config);\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);\n }\n\n private function getClient(): Client\n {\n if (! $this->client instanceof Client) {\n throw new UnexpectedCallException('Client not set');\n }\n\n return $this->client;\n }\n\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_OPPORTUNITY,\n parent::OBJECT_CONTACT,\n parent::OBJECT_ACCOUNT,\n ];\n }\n\n protected function getFields(string $crmObject): array\n {\n // not used\n return [];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Set up the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function syncFields(): void\n {\n $this->syncStandardFields();\n $this->syncCustomFields();\n }\n\n /**\n * @important Works only for custom fields\n */\n public function syncField(Field $field): void\n {\n $resource = $this->convertObjectTypeToResource($field->getObjectType());\n\n // We can only sync custom fields in this CRM.\n if ($this->isCustomField($field->getCrmProviderId()) === false) {\n return;\n }\n\n $crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());\n\n $this->metadataProcessor->syncField($crmField);\n }\n\n private function isCustomField(string $fieldId): bool\n {\n return strpos($fieldId, 'cf_') === 0;\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n // handled in syncFields()\n return [];\n }\n\n /**\n * @important We only support stages on the opportunity object\n *\n * @param string[]|null $types\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n if (! $missingStageName) {\n // This is taken care of by syncOrganization()\n return null;\n }\n\n $stage = $this->stageProcessor->resolveFromStageId($missingStageName);\n\n if ($stage instanceof Stage) {\n return $stage;\n }\n\n $stageMetadata = $this->getClient()->fetchStage($missingStageName);\n\n if (! $stageMetadata) {\n $this->logger->error('Stage does not exist', [\n 'stage' => $missingStageName,\n ]);\n\n return null;\n }\n\n\n return $this->stageProcessor->importStage($stageMetadata);\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Even though Close.io has the concept of \"leads\", they fit more into our concept of accounts.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Not a supported entity.\n return null;\n }\n\n /**\n * @throws Exception\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n foreach ($this->getClient()->listAccounts($since) as $clAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($clAccount->getId())) {\n $this->importAccount($clAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n $this->logger->error('Account sync failed', [\n 'error' => $exception->getMessage(),\n ]);\n\n throw $exception;\n }\n\n return $syncCount;\n }\n\n public function syncAccount(string $crmId): ?Account\n {\n return $this->accountProcessor->syncAccount($crmId);\n }\n\n private function importAccount($crmData): Account\n {\n return $this->accountProcessor->importAccountMetadata($crmData);\n }\n\n /**\n * @throws CloseException\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $strategies = $strategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n $opportunities = [];\n foreach ($strategies as $syncStrategy) {\n $opportunitiesData = $syncStrategy->fetchOpportunities($parameters);\n $opportunities[] = $opportunitiesData['data'];\n\n if ($opportunitiesData['has_more']) {\n $this->logger->info('[Close] Sync Opportunities - count warning', [\n 'team_id' => $this->config->getTeam()->getId(),\n 'total' => $opportunitiesData['total'],\n 'count' => $opportunitiesData['count'],\n 'skip' => $opportunitiesData['skip'],\n 'strategies_count' => count($strategies),\n ]);\n }\n }\n\n $opportunities = array_merge(...$opportunities);\n } catch (CrmException $exception) {\n $this->logger->error('Fetching opportunity data failed', [\n 'team' => $this->getTeam()->getSlug(),\n 'error' => $exception->getMessage(),\n ]);\n\n return 0;\n }\n\n foreach ($opportunities as $opportunityMetadata) {\n try {\n $this->importOpportunity($opportunityMetadata);\n $syncCount++;\n } catch (Exception $exception) {\n $this->logger->warning('Opportunity sync failed', [\n 'opportunity' => $opportunityMetadata->getId(),\n 'error' => $exception->getMessage(),\n ]);\n }\n }\n\n return $syncCount;\n }\n\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n\n $strategy = $strategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n $opportunity = $strategy->fetchOpportunities($parameters);\n\n if (empty($opportunity['data'])) {\n return null;\n }\n\n return $this->importOpportunity($opportunity['data']);\n }\n\n private function importOpportunity(OpportunityMetadata $crmData): ?Opportunity\n {\n if (! $crmData->getLeadId()) {\n $this->logger->warning('Opportunity does not have a lead ID', [\n 'opportunity' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $account = $this->getConfiguration()\n ->accounts()\n ->where('crm_provider_id', $crmData->getLeadId())\n ->first();\n\n if ($account === null) {\n $account = $this->accountProcessor->syncAccount($crmData->getLeadId());\n }\n\n /** @var Profile $profile */\n $profile = $this->getConfiguration()\n ->profiles()\n ->where('crm_provider_id', $crmData->getUserId())\n ->first();\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Close] | Skip import, no user_id found', [\n 'id' => $crmData->getId(),\n ]);\n\n return null;\n }\n\n $stage = $this->getConfiguration()\n ->stages()\n ->where('crm_provider_id', $crmData->getStageId())\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());\n }\n\n return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);\n }\n\n /**\n * @param array<string,string> $crmData\n * @param string[] $crmFields\n */\n public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void\n {\n // handled in importOpportunity\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n /** No way to sync today.\n $clContacts = $this->client->get('lead', [\n 'date_updated__gte' => $since->toDateString(),\n '_order_by' => '-date_updated',\n ]);\n\n foreach ($clContacts as $clContact) {\n // Only sync if previously imported.\n if ($this->hasContact($clContact['id'])) {\n $this->importContact($clContact);\n $syncCount++;\n }\n }\n **/\n } catch (Exception $exception) {\n // Do nothing for now.\n throw $exception;\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $clContact = $this->client->get('contact/' . $crmId);\n } catch (HttpNotFoundException $exception) {\n return null;\n }\n\n return $this->importContact($clContact);\n }\n\n /**\n * @inheritdoc\n */\n private function importContact($crmData): Contact\n {\n $account = null;\n if ($crmData['lead_id']) {\n $account = $this->team\n ->accounts()\n ->where('crm_provider_id', $crmData['lead_id'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['lead_id']);\n }\n }\n\n $mobilePhone = $parsedNumber = null;\n foreach ($crmData['phones'] as $phoneNumber) {\n if ($phoneNumber['type'] === 'mobile') {\n $mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);\n }\n }\n\n $email = null;\n if (empty($crmData['emails']) === false) {\n $email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);\n }\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();\n\n $data = [\n 'account_id' => $account->id ?? null,\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['updated_by'],\n 'name' => $crmData['name'] ?? 'Unknown',\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobilePhone ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['id'],\n modelType: Contact::class,\n fileName: $crmData['id'],\n avatarText: $crmData['name'] ?? 'Unknown'\n ),\n 'remotely_created_at' => Carbon::parse($crmData['date_created']),\n ];\n\n /** @var Contact */\n return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);\n }\n\n private function buildContactPhone(?string $countryCode, ?string $number): ?array\n {\n if ($number) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($number, 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n return $parsedNumber;\n }\n\n private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string\n {\n return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;\n }\n\n public function syncOrganization(): void\n {\n $organisation = $this->getClient()->fetchOrganisation();\n\n $this->metadataProcessor->syncOrganisation($organisation);\n\n foreach ($organisation->getPipelines() as $pipelineMetadata) {\n $this->metadataProcessor->syncPipeline($pipelineMetadata);\n }\n }\n\n private function syncStandardFields(): void\n {\n // Currently we sync only opportunity fields\n $stages = $this->getClient()->listStages();\n foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n\n $this->config->save();\n }\n\n private function syncCustomFields(): void\n {\n foreach ($this->getFieldTypes() as $fieldType) {\n $objectType = $this->convertObjectTypeToResource($fieldType);\n $currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);\n\n foreach ($currentFields as $fieldMetadata) {\n $this->metadataProcessor->syncField($fieldMetadata);\n }\n }\n\n $this->config->save();\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n /*\n * Fetch the profile of the user from the database\n * Then fetch the user metadata from Close and update it\n * In case there's no profile for the user, proceed with syncing all users\n */\n $foundUser = null;\n\n if ($userToSearch) {\n $profile = $userToSearch->getProfile();\n\n if ($profile instanceof Profile) {\n $crmProviderId = $profile->getCrmProviderId();\n\n if ($crmProviderId) {\n $profileMetadata = $this->getClient()->fetchUser($crmProviderId);\n\n if (! $profileMetadata instanceof ProfileMetadata) {\n return null;\n }\n\n return $this->metadataProcessor->syncProfile($profileMetadata);\n }\n }\n }\n\n foreach ($this->getClient()->listUsers() as $userMetadata) {\n $userProfile = $this->metadataProcessor->syncProfile($userMetadata);\n\n if (\n $userToSearch instanceof User\n && $userProfile instanceof Profile\n && $userProfile->getUserId() === $userToSearch->getId()\n ) {\n $foundUser = $userProfile;\n }\n }\n\n return $foundUser;\n }\n\n public function syncProfileFields(): void\n {\n // Not used.\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n $data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {\n $data = [];\n\n try {\n // If search phrase resembles phone number remove special symbols\n if (preg_match('/^([0-9\\s\\-\\+\\(\\)]*)$/', $name)) {\n $name = '+' . preg_replace('/[\\s\\-\\+\\(\\)]/', '', $name);\n }\n\n // Close do not provide a unified way to search, so we must hack our own.\n $objects = $this->client->get('lead', [\n 'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',\n '_limit' => $count, '_skip' => $offset,\n ]);\n } catch (\\GuzzleHttp\\Exception\\ServerException $exception) {\n throw new ServiceUnavailableException($exception->getMessage());\n }\n\n foreach ($objects['data'] as $object) {\n // We need a contact to dial it.\n if (empty($object['contacts'])) {\n continue;\n }\n\n foreach ($object['contacts'] as $contact) {\n $record = [\n 'crmId' => $contact['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),\n 'name' => $contact['name'],\n 'industry' => null,\n 'title' => $contact['title'],\n 'organization' => $object['display_name'],\n 'prospectType' => 'contact',\n 'phoneNumbers' => [],\n ];\n\n foreach ($contact['phones'] as $phone) {\n if ($phone['type'] === 'mobile') {\n $number = $this->buildContactMobilePhone(null, $phone['phone']);\n\n $record['phoneNumbers'][] = [\n 'number' => $number,\n 'nationalFormat' => phone_national(null, $number),\n 'type' => 'mobile',\n ];\n } else {\n $parsedNumber = $this->buildContactPhone(null, $phone['phone']);\n\n // Add phone number to record.\n if (empty($parsedNumber['phone']) === false) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national(null, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n }\n }\n\n $data[] = $record;\n }\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n $contact = null;\n $account = null;\n\n if ($crmAccountId) {\n $account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmAccountId);\n }\n }\n\n if ($crmContactId) {\n $contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($crmContactId);\n }\n }\n\n if ($contact || $account) {\n if ($contact && $account === null) {\n $account = $contact->account;\n }\n\n if ($account === null) {\n return [];\n }\n\n $params = [\n 'lead_id' => $account->crm_provider_id,\n '_order_by' => '-date_updated',\n ];\n\n $onlyOpen = true;\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['_order_by'] = '-date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['_order_by'] = 'date_created';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['_order_by'] = '-date_updated';\n $onlyOpen = false;\n }\n\n if ($onlyOpen) {\n $params['status_type__in'] = 'active,won';\n }\n\n $clOpportunities = $this->client->get('opportunity', $params);\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n foreach ($clOpportunities['data'] as $clOpportunity) {\n $stage = $this->config\n ->stages()\n ->where('crm_provider_id', $clOpportunity['status_id'])\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);\n }\n\n $record = [\n 'crmId' => $clOpportunity['id'],\n 'name' => $clOpportunity['note'],\n 'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),\n 'won' => $stage->probability === 100.00,\n 'closed' => $clOpportunity['status_type'] !== 'active',\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n 'recordType' => [],\n ];\n\n if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $crmId = null;\n\n if ($objectType === 'contact') {\n $contact = $this->syncContact($objectId);\n\n if ($contact && $contact->account_id) {\n $crmId = $contact->account->crm_provider_id;\n }\n } else {\n $crmId = $objectId;\n }\n\n if ($crmId) {\n $clTasks = $this->client->get('task', [\n 'lead_id' => $crmId,\n '_type' => 'lead',\n 'assigned_to' => $this->profile->crm_provider_id,\n 'is_complete' => 'false',\n '_order_by' => 'date',\n ]);\n\n foreach ($clTasks['data'] as $clTask) {\n $data[] = [\n 'crmId' => $clTask['id'],\n 'subject' => $clTask['text'],\n 'due' => $clTask['date'] ?? null,\n 'type' => null,\n ];\n }\n }\n\n return $data;\n }\n\n /**\n * Try to find email address in CRM service\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(email(email:\"' . $email . '\"))',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['emails'] as $clEmail) {\n if ($email === $clEmail['email']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Check if the user is internal.\n $teamMember = $this->team->users()->where('phone', $phone)->exists();\n\n // Skip the attendee if internal.\n if ($teamMember === false) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(' . $phone . ')',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n foreach ($clContact['phones'] as $clPhone) {\n if ($phone === $clPhone['phone']) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n }\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $clObject = $this->client->get('lead', [\n 'query' => 'sort:updated contact(name:\"' . $name . '\")',\n '_limit' => 1,\n ]);\n\n if ($clObject['total_results'] > 0) {\n foreach ($clObject['data'][0]['contacts'] as $clContact) {\n if ($clContact['name'] === $name || $clContact['display_name'] === $name) {\n $data = $this->convertCrmData($clContact['id'], $userId);\n\n return ! empty(array_filter($data)) ? $data : false;\n }\n }\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n private function convertCrmData(string $crmId, ?int $userId = null): array\n {\n $lead = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n $contact = $this->syncContact($crmId);\n if ($contact) {\n $account = $contact->account;\n\n if ($contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $cpOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId,\n );\n\n if (! empty($cpOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function mapStatus(string $status): string\n {\n switch ($status) {\n case Activity::STATUS_COMPLETED:\n case Activity::STATUS_IN_PROGRESS:\n case Activity::STATUS_FAILED:\n case Activity::STATUS_NO_ANSWER:\n case Activity::STATUS_BUSY:\n default:\n return $status;\n case Activity::STATUS_CANCELLED:\n return 'cancel';\n }\n }\n\n /**\n * @throws CrmException\n */\n private function buildCallPayload(Activity $activity): Activity\n {\n try {\n if ($activity->crm_provider_id) {\n // The activity should be logged under the existing Task (not Activity).\n $data = [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $this->generateActivityDescription($activity),\n 'date' => $activity->getActualEndTime()->toDateString(),\n 'is_complete' => true,\n ];\n\n $this->logger->info('[Close CRM] Updating task', [\n 'activity' => $activity->id,\n 'crm_id' => $activity->crm_provider_id,\n 'data' => $data,\n ]);\n\n $this->client->put('task/' . $activity->crm_provider_id, $data);\n } else {\n // Just create an activity.\n $data = [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',\n 'status' => $this->mapStatus($activity->getStatus()),\n 'note' => $this->generateActivityDescription($activity),\n 'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,\n 'phone' => $activity->to ? $activity->to->phone_number : null,\n ];\n\n $clActivity = $this->client->post('activity/call', $data);\n\n $this->logger->info('[Close CRM] Creating activity', [\n 'activity' => $activity->id,\n 'crm_id' => $clActivity['id'],\n 'data' => $data,\n 'response' => $clActivity,\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n }\n } catch (ClientException $exception) {\n $response = $exception->getResponse();\n\n if ($response === null) {\n // Trying to debug weird cases where this is null.\n Sentry::captureException($exception);\n }\n\n $responseBody = $response->getBody();\n $message = $responseBody;\n $errorCode = $response->getStatusCode();\n\n $jsonResponse = json_decode($responseBody, true);\n if (isset($jsonResponse[0]['message'])) {\n $message = $jsonResponse[0]['message'];\n }\n\n throw new CrmException($message, $errorCode);\n }\n\n return $activity;\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $clActivity = $this->client->post('activity/sms', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',\n 'text' => $this->generateActivityDescription($activity),\n 'remote_phone' => $activity->to ? $activity->to->phone_number : null,\n 'local_phone' => $activity->to ? $activity->to->phone_number : null,\n 'source' => 'Close.io',\n ]);\n\n $activity->crm_provider_id = $clActivity['id'];\n $activity->save();\n\n return $activity;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = '';\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n if ($activity->hasActivityType()) {\n $description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;\n }\n if ($activity->hasTitle()) {\n $description .= $activity->getTitle() . PHP_EOL;\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= 'Attendees:'\n . PHP_EOL\n . (new FilterJoinedParticipants())->toString($activity);\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= $time . ' ' . $note->note . PHP_EOL;\n }\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;\n\n foreach ($messages->get() as $message) {\n $description .= $message->participant->name . ': ' . $message->message . PHP_EOL;\n }\n }\n\n if ($activity->summary) {\n $description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $description = $activity->description;\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity subject field.\n if (empty($fields['name'])) {\n return null;\n }\n\n $due = null;\n if (empty($fields['due_date']) === false) {\n $formatDue = Carbon::parse($fields['due_date']);\n $due = $formatDue->toDateTimeString();\n }\n\n $clTask = $this->client->post('task', [\n '_type' => 'lead',\n 'lead_id' => $activity->account->crm_provider_id,\n 'assigned_to' => $this->profile->crm_provider_id,\n 'text' => $fields['name'],\n 'date' => $due,\n 'is_complete' => false,\n ]);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $clTask['id'];\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n if ($activity->account_id === null) {\n // We can only log to accounts (leads).\n return;\n }\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $clActivity = $this->client->post('activity/note', [\n 'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,\n 'created_by' => $this->profile->crm_provider_id,\n 'user_id' => $this->profile->crm_provider_id,\n 'note' => $transcripts,\n ]);\n\n // Store CRM Activity ID in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $clActivity['id'];\n $transcription->save();\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, 'lead')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, 'cont')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, 'oppo')) {\n return 'opportunity';\n }\n\n throw new InvalidArgumentException('Unsupported Object Type');\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($crmObject instanceof Lead) {\n // This would never get invoked since we merge lead/accounts in Close.\n $this->client->put('lead/' . $crmObject->crm_provider_id, [\n 'status' => $stage->crm_provider_id,\n ]);\n } else {\n $this->client->put('opportunity/' . $crmObject->crm_provider_id, [\n 'status_id' => $stage->crm_provider_id,\n ]);\n }\n }\n\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);\n }\n\n public function prepareValueForUpdate(array $params): array\n {\n $convertedValue = $this->fieldValueConverter->convertToCrm(\n $this->config,\n $params['fieldName'],\n $params['fieldValue'],\n );\n\n if ($this->isCustomField($params['fieldName'])) {\n $params['fieldName'] = 'custom.' . $params['fieldName'];\n }\n\n $params['fieldValue'] = $convertedValue;\n\n return parent::prepareValueForUpdate($params);\n }\n\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);\n }\n\n /**\n *\n * @throws UnexpectedValueException\n */\n private function convertObjectTypeToResource(string $objectType): string\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return 'opportunity';\n\n case FieldData::OBJECT_CONTACT:\n return 'contact';\n\n case FieldData::OBJECT_ACCOUNT:\n return 'lead';\n\n case FieldData::OBJECT_TASK:\n return 'activity';\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $baseUrl = 'https://app.close.com/';\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'lead/' . $providerId;\n\n break;\n\n case 'contact':\n $contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();\n if ($contact && $contact->account_id) {\n $url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;\n }\n\n break;\n\n default:\n // Sadly we can't deeplink to anything else in Close UI.\n $url = null;\n }\n\n return $url;\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $client = $this->getClient();\n $task = $client->get('task/' . $crmProviderId);\n\n return ! empty($task);\n } catch (HttpNotFoundException) {\n // Task not found in CRM - this is expected and permanent\n $this->logger->info('[Close] Task not found during verification', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n } catch (CloseException $e) {\n // Handle 404 responses from Close API\n if ($e->getResponseStatusCode() === 404) {\n $this->logger->info('[Close] Task not found during verification (404)', [\n 'task_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n // Re-throw other Close exceptions for retry\n throw $e;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"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":"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\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Component\\ActivitySearch\\FilterDefinition;\n\nuse Illuminate\\Support\\Collection;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinition;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQuery;\nuse Jiminny\\Component\\ActivitySearch\\FilterDefinitionQueryCollection;\nuse Jiminny\\Contracts\\ActivitySearch\\ValidatedFilterDefinitionInterface;\nuse Jiminny\\Contracts\\Repositories\\UserRepository;\nuse Jiminny\\Models;\nuse Jiminny\\Models\\Feature\\FeatureEnum;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Traits\\RequiresUUID;\nuse Elastica\\Query;\n\nfinal class CoachingFeedbackCoachUserIn extends FilterDefinition implements ValidatedFilterDefinitionInterface\n{\n private const int NO_GROUP_ID = 999;\n private UserRepository $userRepository;\n\n public function __construct(UserRepository $userRepository)\n {\n $this->userRepository = $userRepository;\n }\n\n public function shouldApplyQueries(): bool\n {\n return count($this->getValue()) > 0;\n }\n\n public function getQueries(): FilterDefinitionQueryCollection\n {\n $userIds = $this->getValue();\n\n return FilterDefinitionQueryCollection::make([\n FilterDefinitionQuery::instance()\n ->setQuery(new Query\\Terms('coachingFeedbacks.coach.id_string', $userIds))\n ->setPath('coachingFeedbacks.coach', 'coachingFeedbacks'),\n ]);\n }\n\n public function toArray(): array\n {\n return [\n 'id' => 'coaching-feedback-user-filter',\n 'label' => 'Coach',\n 'helpText' => 'The person who completed the Coaching Framework',\n 'placeholder' => 'Search coaches',\n 'inputType' => InputTypeEnum::DROPDOWN_MULTIPLE,\n 'queryParam' => 'coaching_feedback_coach_id',\n 'options' => $this->getOptions(),\n 'groupLabelKey' => 'label',\n 'groupValuesKey' => 'users',\n 'optionLabelKey' => 'name',\n 'optionValueKey' => 'id',\n 'value' => $this->getValue(),\n ];\n }\n\n private function getOptions(): array\n {\n $user = $this->executedBy;\n $query = $user\n ->getTeam()\n ->users()\n ->whereNotIn(\n 'uuid',\n $this->userRepository->findInaccessibleDeactivatedUserUuids($user)\n ->map(static fn (string $uuid): string => RequiresUUID::toOptimized($uuid))\n ->all(),\n )\n ->orderBy('name');\n\n $users = $query\n ->with('group')\n ->get()\n ->reduce(\n static function (array $carry, Models\\User $user): array {\n $group = $user->getGroup();\n $groupId = $group !== null\n ? $group->getUuid()\n : self::NO_GROUP_ID;\n $groupName = $group !== null\n ? $group->getName()\n : '(NON-GROUPED)';\n\n if (! array_key_exists($groupId, $carry)) {\n $carry[$groupId] = [\n 'label' => $groupName,\n 'users' => [],\n ];\n }\n\n $carry[$groupId]['users'][] = [\n 'id' => $user->getUuid(),\n 'name' => sprintf(\n '%s%s',\n $user->getName(),\n ! $user->isStatusActive()\n ? ' (Inactive)'\n : ''\n ),\n ];\n\n return $carry;\n },\n []\n );\n\n return Collection::make($users)\n ->sortBy('label')\n ->values()\n ->toArray();\n }\n\n public function getValue(): array\n {\n if (! $this->criteria->hasCoachingFeedbackCoachUserIds()) {\n return $this->getDefaultValue();\n }\n\n return array_diff(\n $this->criteria->getCoachingFeedbackCoachUserId(),\n $this->userRepository->findInaccessibleDeactivatedUserUuids($this->executedBy)->all(),\n );\n }\n\n private function getDefaultValue(): array\n {\n return [];\n }\n\n public function getValidationRules(?string $prefix = null): array\n {\n $team = $this->executedBy->getTeam();\n\n return [\n $prefix . 'coaching_feedback_coach_id' => 'array',\n $prefix . 'coaching_feedback_coach_id.*' => 'uuid:users,team_id,' . $team->getId(),\n ];\n }\n\n public function getSortOrder(): int\n {\n return 86;\n }\n\n public function shouldBeIncluded(Team $team): bool\n {\n return $team->hasFeature(FeatureEnum::COACHING_FRAMEWORK);\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,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6754415607117048428
|
-9030663327281178587
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
master, menu
Start Listen 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
Code changed:
Hide
Sync Changes
Hide This Notification
8
39
5
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Close;
use Cache;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\CloseInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Exceptions\UnexpectedCallException;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Close\Processor\AccountProcessor;
use Jiminny\Services\Crm\Close\Processor\MetadataProcessor;
use Jiminny\Services\Crm\Close\Processor\OpportunityProcessor;
use Jiminny\Services\Crm\Close\Processor\StageProcessor;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Services\Crm\Metadata\OpportunityMetadata;
use Jiminny\Services\Crm\Metadata\ProfileMetadata;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Sentry;
use UnexpectedValueException;
class Service extends BaseService implements
CloseInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private StandardFieldMetadata $standardFieldMetadata;
private MetadataProcessor $metadataProcessor;
private FieldValueConverter $fieldValueConverter;
private StageProcessor $stageProcessor;
private OpportunityProcessor $opportunityProcessor;
private AccountProcessor $accountProcessor;
public function __construct(
Client $client,
StandardFieldMetadata $standardFieldMetadata,
MetadataProcessor $metadataProcessor,
FieldValueConverter $fieldValueConverter,
StageProcessor $stageResolver,
OpportunityProcessor $opportunityProcessor,
AccountProcessor $accountProcessor,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->standardFieldMetadata = $standardFieldMetadata;
$this->metadataProcessor = $metadataProcessor;
$this->fieldValueConverter = $fieldValueConverter;
$this->stageProcessor = $stageResolver;
$this->opportunityProcessor = $opportunityProcessor;
$this->accountProcessor = $accountProcessor;
}
public function getDisplayName(): string
{
return 'Close';
}
public function setConfiguration(Configuration $config): void
{
parent::setConfiguration($config);
$this->metadataProcessor->setConfiguration($config);
$this->stageProcessor->setConfiguration($config);
$this->opportunityProcessor->setConfiguration($config);
$this->accountProcessor->setConfiguration($config);
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
return $user->getTeam()->getOwner()->getSocialAccount(SocialAccount::PROVIDER_CLOSE);
}
private function getClient(): Client
{
if (! $this->client instanceof Client) {
throw new UnexpectedCallException('Client not set');
}
return $this->client;
}
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return $this->fieldValueConverter->convertFromCrm($fieldType, $fieldValue);
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_OPPORTUNITY,
parent::OBJECT_CONTACT,
parent::OBJECT_ACCOUNT,
];
}
protected function getFields(string $crmObject): array
{
// not used
return [];
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Set up the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
public function syncFields(): void
{
$this->syncStandardFields();
$this->syncCustomFields();
}
/**
* @important Works only for custom fields
*/
public function syncField(Field $field): void
{
$resource = $this->convertObjectTypeToResource($field->getObjectType());
// We can only sync custom fields in this CRM.
if ($this->isCustomField($field->getCrmProviderId()) === false) {
return;
}
$crmField = $this->getClient()->fetchCustomFieldDefinition($resource, $field->getCrmProviderId());
$this->metadataProcessor->syncField($crmField);
}
private function isCustomField(string $fieldId): bool
{
return strpos($fieldId, 'cf_') === 0;
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
// handled in syncFields()
return [];
}
/**
* @important We only support stages on the opportunity object
*
* @param string[]|null $types
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
if (! $missingStageName) {
// This is taken care of by syncOrganization()
return null;
}
$stage = $this->stageProcessor->resolveFromStageId($missingStageName);
if ($stage instanceof Stage) {
return $stage;
}
$stageMetadata = $this->getClient()->fetchStage($missingStageName);
if (! $stageMetadata) {
$this->logger->error('Stage does not exist', [
'stage' => $missingStageName,
]);
return null;
}
return $this->stageProcessor->importStage($stageMetadata);
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Even though Close.io has the concept of "leads", they fit more into our concept of accounts.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Not a supported entity.
return null;
}
/**
* @throws Exception
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
foreach ($this->getClient()->listAccounts($since) as $clAccount) {
// Only sync if previously imported.
if ($this->hasAccount($clAccount->getId())) {
$this->importAccount($clAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
$this->logger->error('Account sync failed', [
'error' => $exception->getMessage(),
]);
throw $exception;
}
return $syncCount;
}
public function syncAccount(string $crmId): ?Account
{
return $this->accountProcessor->syncAccount($crmId);
}
private function importAccount($crmData): Account
{
return $this->accountProcessor->importAccountMetadata($crmData);
}
/**
* @throws CloseException
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategies = $strategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
$opportunities = [];
foreach ($strategies as $syncStrategy) {
$opportunitiesData = $syncStrategy->fetchOpportunities($parameters);
$opportunities[] = $opportunitiesData['data'];
if ($opportunitiesData['has_more']) {
$this->logger->info('[Close] Sync Opportunities - count warning', [
'team_id' => $this->config->getTeam()->getId(),
'total' => $opportunitiesData['total'],
'count' => $opportunitiesData['count'],
'skip' => $opportunitiesData['skip'],
'strategies_count' => count($strategies),
]);
}
}
$opportunities = array_merge(...$opportunities);
} catch (CrmException $exception) {
$this->logger->error('Fetching opportunity data failed', [
'team' => $this->getTeam()->getSlug(),
'error' => $exception->getMessage(),
]);
return 0;
}
foreach ($opportunities as $opportunityMetadata) {
try {
$this->importOpportunity($opportunityMetadata);
$syncCount++;
} catch (Exception $exception) {
$this->logger->warning('Opportunity sync failed', [
'opportunity' => $opportunityMetadata->getId(),
'error' => $exception->getMessage(),
]);
}
}
return $syncCount;
}
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$strategy = $strategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
$opportunity = $strategy->fetchOpportunities($parameters);
if (empty($opportunity['data'])) {
return null;
}
return $this->importOpportunity($opportunity['data']);
}
private function importOpportunity(OpportunityMetadata $crmData): ?Opportunity
{
if (! $crmData->getLeadId()) {
$this->logger->warning('Opportunity does not have a lead ID', [
'opportunity' => $crmData->getId(),
]);
return null;
}
$account = $this->getConfiguration()
->accounts()
->where('crm_provider_id', $crmData->getLeadId())
->first();
if ($account === null) {
$account = $this->accountProcessor->syncAccount($crmData->getLeadId());
}
/** @var Profile $profile */
$profile = $this->getConfiguration()
->profiles()
->where('crm_provider_id', $crmData->getUserId())
->first();
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Close] | Skip import, no user_id found', [
'id' => $crmData->getId(),
]);
return null;
}
$stage = $this->getConfiguration()
->stages()
->where('crm_provider_id', $crmData->getStageId())
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData->getStageId());
}
return $this->opportunityProcessor->import($crmData, $account, $stage, $profile);
}
/**
* @param array<string,string> $crmData
* @param string[] $crmFields
*/
public function importOpportunityCrmFieldData(array $crmData, array $crmFields, int $opportunityId): void
{
// handled in importOpportunity
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
/** No way to sync today.
$clContacts = $this->client->get('lead', [
'date_updated__gte' => $since->toDateString(),
'_order_by' => '-date_updated',
]);
foreach ($clContacts as $clContact) {
// Only sync if previously imported.
if ($this->hasContact($clContact['id'])) {
$this->importContact($clContact);
$syncCount++;
}
}
**/
} catch (Exception $exception) {
// Do nothing for now.
throw $exception;
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$clContact = $this->client->get('contact/' . $crmId);
} catch (HttpNotFoundException $exception) {
return null;
}
return $this->importContact($clContact);
}
/**
* @inheritdoc
*/
private function importContact($crmData): Contact
{
$account = null;
if ($crmData['lead_id']) {
$account = $this->team
->accounts()
->where('crm_provider_id', $crmData['lead_id'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['lead_id']);
}
}
$mobilePhone = $parsedNumber = null;
foreach ($crmData['phones'] as $phoneNumber) {
if ($phoneNumber['type'] === 'mobile') {
$mobilePhone = $this->buildContactMobilePhone(null, $phoneNumber['phone']);
} else {
$parsedNumber = $this->buildContactPhone(null, $phoneNumber['phone']);
}
}
$email = null;
if (empty($crmData['emails']) === false) {
$email = mb_strimwidth($crmData['emails'][0]['email'], 0, 80);
}
$profile = $this->config->profiles()->where('crm_provider_id', (string) $crmData['updated_by'])->first();
$data = [
'account_id' => $account->id ?? null,
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['updated_by'],
'name' => $crmData['name'] ?? 'Unknown',
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobilePhone ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => mb_strimwidth($crmData['title'] ?? '', 0, 128),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['id'],
modelType: Contact::class,
fileName: $crmData['id'],
avatarText: $crmData['name'] ?? 'Unknown'
),
'remotely_created_at' => Carbon::parse($crmData['date_created']),
];
/** @var Contact */
return $this->config->contacts()->updateOrCreate(['crm_provider_id' => (string) $crmData['id']], $data);
}
private function buildContactPhone(?string $countryCode, ?string $number): ?array
{
if ($number) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($number, 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
return $parsedNumber;
}
private function buildContactMobilePhone(?string $countryCode, ?string $number): ?string
{
return $number ? mb_strimwidth(phone_e164($countryCode, $number), 0, 25) : null;
}
public function syncOrganization(): void
{
$organisation = $this->getClient()->fetchOrganisation();
$this->metadataProcessor->syncOrganisation($organisation);
foreach ($organisation->getPipelines() as $pipelineMetadata) {
$this->metadataProcessor->syncPipeline($pipelineMetadata);
}
}
private function syncStandardFields(): void
{
// Currently we sync only opportunity fields
$stages = $this->getClient()->listStages();
foreach ($this->standardFieldMetadata->opportunity($stages) as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
$this->config->save();
}
private function syncCustomFields(): void
{
foreach ($this->getFieldTypes() as $fieldType) {
$objectType = $this->convertObjectTypeToResource($fieldType);
$currentFields = $this->getClient()->listCustomFieldDefinitions($objectType);
foreach ($currentFields as $fieldMetadata) {
$this->metadataProcessor->syncField($fieldMetadata);
}
}
$this->config->save();
}
public function syncProfiles(?User $userToSearch = null): ?Profile
{
/*
* Fetch the profile of the user from the database
* Then fetch the user metadata from Close and update it
* In case there's no profile for the user, proceed with syncing all users
*/
$foundUser = null;
if ($userToSearch) {
$profile = $userToSearch->getProfile();
if ($profile instanceof Profile) {
$crmProviderId = $profile->getCrmProviderId();
if ($crmProviderId) {
$profileMetadata = $this->getClient()->fetchUser($crmProviderId);
if (! $profileMetadata instanceof ProfileMetadata) {
return null;
}
return $this->metadataProcessor->syncProfile($profileMetadata);
}
}
}
foreach ($this->getClient()->listUsers() as $userMetadata) {
$userProfile = $this->metadataProcessor->syncProfile($userMetadata);
if (
$userToSearch instanceof User
&& $userProfile instanceof Profile
&& $userProfile->getUserId() === $userToSearch->getId()
) {
$foundUser = $userProfile;
}
}
return $foundUser;
}
public function syncProfileFields(): void
{
// Not used.
}
/**
* @inheritdoc
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
$data = Cache::remember($this->team->id . $name . $count . $offset, 300, function () use ($name, $count, $offset) {
$data = [];
try {
// If search phrase resembles phone number remove special symbols
if (preg_match('/^([0-9\s\-\+\(\)]*)$/', $name)) {
$name = '+' . preg_replace('/[\s\-\+\(\)]/', '', $name);
}
// Close do not provide a unified way to search, so we must hack our own.
$objects = $this->client->get('lead', [
'query' => 'sort:date_updated name:(' . $name . ') or email:(' . $name . ') or phone:(' . $name . ')',
'_limit' => $count, '_skip' => $offset,
]);
} catch (\GuzzleHttp\Exception\ServerException $exception) {
throw new ServiceUnavailableException($exception->getMessage());
}
foreach ($objects['data'] as $object) {
// We need a contact to dial it.
if (empty($object['contacts'])) {
continue;
}
foreach ($object['contacts'] as $contact) {
$record = [
'crmId' => $contact['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], 'account'),
'name' => $contact['name'],
'industry' => null,
'title' => $contact['title'],
'organization' => $object['display_name'],
'prospectType' => 'contact',
'phoneNumbers' => [],
];
foreach ($contact['phones'] as $phone) {
if ($phone['type'] === 'mobile') {
$number = $this->buildContactMobilePhone(null, $phone['phone']);
$record['phoneNumbers'][] = [
'number' => $number,
'nationalFormat' => phone_national(null, $number),
'type' => 'mobile',
];
} else {
$parsedNumber = $this->buildContactPhone(null, $phone['phone']);
// Add phone number to record.
if (empty($parsedNumber['phone']) === false) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national(null, $parsedNumber['phone']),
'type' => 'phone',
];
}
}
}
$data[] = $record;
}
}
return $data;
});
return $data;
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
$contact = null;
$account = null;
if ($crmAccountId) {
$account = $this->config->accounts()->where('crm_provider_id', $crmAccountId)->first();
if ($account === null) {
$account = $this->syncAccount($crmAccountId);
}
}
if ($crmContactId) {
$contact = $this->config->contacts()->where('crm_provider_id', $crmContactId)->first();
if ($contact === null) {
$contact = $this->syncContact($crmContactId);
}
}
if ($contact || $account) {
if ($contact && $account === null) {
$account = $contact->account;
}
if ($account === null) {
return [];
}
$params = [
'lead_id' => $account->crm_provider_id,
'_order_by' => '-date_updated',
];
$onlyOpen = true;
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['_order_by'] = '-date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['_order_by'] = 'date_created';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['_order_by'] = '-date_updated';
$onlyOpen = false;
}
if ($onlyOpen) {
$params['status_type__in'] = 'active,won';
}
$clOpportunities = $this->client->get('opportunity', $params);
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
foreach ($clOpportunities['data'] as $clOpportunity) {
$stage = $this->config
->stages()
->where('crm_provider_id', $clOpportunity['status_id'])
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $clOpportunity['status_id']);
}
$record = [
'crmId' => $clOpportunity['id'],
'name' => $clOpportunity['note'],
'value' => currency_format($clOpportunity['value'], $clOpportunity['value_currency']),
'won' => $stage->probability === 100.00,
'closed' => $clOpportunity['status_type'] !== 'active',
'stage' => [
'id' => $stage->id_string,
'name' => $stage->name,
],
'recordType' => [],
];
if ($ownerId && isset($clOpportunity['user_id']) && $clOpportunity['user_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$crmId = null;
if ($objectType === 'contact') {
$contact = $this->syncContact($objectId);
if ($contact && $contact->account_id) {
$crmId = $contact->account->crm_provider_id;
}
} else {
$crmId = $objectId;
}
if ($crmId) {
$clTasks = $this->client->get('task', [
'lead_id' => $crmId,
'_type' => 'lead',
'assigned_to' => $this->profile->crm_provider_id,
'is_complete' => 'false',
'_order_by' => 'date',
]);
foreach ($clTasks['data'] as $clTask) {
$data[] = [
'crmId' => $clTask['id'],
'subject' => $clTask['text'],
'due' => $clTask['date'] ?? null,
'type' => null,
];
}
}
return $data;
}
/**
* Try to find email address in CRM service
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(email(email:"' . $email . '"))',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['emails'] as $clEmail) {
if ($email === $clEmail['email']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
// Check if the user is internal.
$teamMember = $this->team->users()->where('phone', $phone)->exists();
// Skip the attendee if internal.
if ($teamMember === false) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(' . $phone . ')',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
foreach ($clContact['phones'] as $clPhone) {
if ($phone === $clPhone['phone']) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : null;
}
}
}
}
}
return null;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$clObject = $this->client->get('lead', [
'query' => 'sort:updated contact(name:"' . $name . '")',
'_limit' => 1,
]);
if ($clObject['total_results'] > 0) {
foreach ($clObject['data'][0]['contacts'] as $clContact) {
if ($clContact['name'] === $name || $clContact['display_name'] === $name) {
$data = $this->convertCrmData($clContact['id'], $userId);
return ! empty(array_filter($data)) ? $data : false;
}
}
}
return false;
});
return is_array($result) ? $result : null;
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
private function convertCrmData(string $crmId, ?int $userId = null): array
{
$lead = null;
$opportunity = null;
$account = null;
$stage = null;
$countryCode = null;
$contact = $this->syncContact($crmId);
if ($contact) {
$account = $contact->account;
if ($contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account) {
$countryCode = $account->country_code;
}
try {
$cpOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId,
);
if (! empty($cpOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($cpOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception) {
// Nothing to see here.
}
}
return [
$lead,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
public function saveActivity(Activity $activity): Activity
{
switch ($activity->type) {
case Activity::TYPE_CONFERENCE:
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$activity = $this->buildCallPayload($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$activity = $this->buildTextMessagePayload($activity);
break;
}
return $activity;
}
private function mapStatus(string $status): string
{
switch ($status) {
case Activity::STATUS_COMPLETED:
case Activity::STATUS_IN_PROGRESS:
case Activity::STATUS_FAILED:
case Activity::STATUS_NO_ANSWER:
case Activity::STATUS_BUSY:
default:
return $status;
case Activity::STATUS_CANCELLED:
return 'cancel';
}
}
/**
* @throws CrmException
*/
private function buildCallPayload(Activity $activity): Activity
{
try {
if ($activity->crm_provider_id) {
// The activity should be logged under the existing Task (not Activity).
$data = [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $this->generateActivityDescription($activity),
'date' => $activity->getActualEndTime()->toDateString(),
'is_complete' => true,
];
$this->logger->info('[Close CRM] Updating task', [
'activity' => $activity->id,
'crm_id' => $activity->crm_provider_id,
'data' => $data,
]);
$this->client->put('task/' . $activity->crm_provider_id, $data);
} else {
// Just create an activity.
$data = [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'direction' => $activity->getCrmType() === Activity::TYPE_SOFTPHONE ? 'outbound' : 'inbound',
'status' => $this->mapStatus($activity->getStatus()),
'note' => $this->generateActivityDescription($activity),
'duration' => $this->mapStatus($activity->getStatus()) !== 'cancel' ? $activity->duration : 0,
'phone' => $activity->to ? $activity->to->phone_number : null,
];
$clActivity = $this->client->post('activity/call', $data);
$this->logger->info('[Close CRM] Creating activity', [
'activity' => $activity->id,
'crm_id' => $clActivity['id'],
'data' => $data,
'response' => $clActivity,
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
}
} catch (ClientException $exception) {
$response = $exception->getResponse();
if ($response === null) {
// Trying to debug weird cases where this is null.
Sentry::captureException($exception);
}
$responseBody = $response->getBody();
$message = $responseBody;
$errorCode = $response->getStatusCode();
$jsonResponse = json_decode($responseBody, true);
if (isset($jsonResponse[0]['message'])) {
$message = $jsonResponse[0]['message'];
}
throw new CrmException($message, $errorCode);
}
return $activity;
}
private function buildTextMessagePayload(Activity $activity): Activity
{
$clActivity = $this->client->post('activity/sms', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'contact_id' => $activity->contact_id ? $activity->contact->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'status' => $activity->getType() === Activity::TYPE_SMS_OUTBOUND ? 'sent' : 'inbox',
'text' => $this->generateActivityDescription($activity),
'remote_phone' => $activity->to ? $activity->to->phone_number : null,
'local_phone' => $activity->to ? $activity->to->phone_number : null,
'source' => 'Close.io',
]);
$activity->crm_provider_id = $clActivity['id'];
$activity->save();
return $activity;
}
private function generateActivityDescription(Activity $activity): string
{
$description = '';
switch ($activity->type) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
case Activity::TYPE_CONFERENCE:
if ($activity->hasActivityType()) {
$description = $activity->getActivityType()->getName() . PHP_EOL . PHP_EOL;
}
if ($activity->hasTitle()) {
$description .= $activity->getTitle() . PHP_EOL;
}
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . PHP_EOL . PHP_EOL;
// When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' . PHP_EOL . PHP_EOL;
} elseif ($activity->canReviewActivity()) {
$playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . PHP_EOL . PHP_EOL;
}
if ($activity->type === Activity::TYPE_CONFERENCE) {
$description .= 'Attendees:'
. PHP_EOL
. (new FilterJoinedParticipants())->toString($activity);
}
if (\count($activity->notes) > 0) {
$description .= PHP_EOL . PHP_EOL . 'Notes: ' . PHP_EOL;
foreach ($activity->notes as $note) {
$time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);
$description .= $time . ' ' . $note->note . PHP_EOL;
}
}
// Get all private messages.
$messages = $activity->messages()
->where('is_private', 1)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Coaching Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
// Get all public messages.
$messages = $activity->messages()
->where('is_private', 0)
->orderBy('created_at', 'asc');
if ($messages->count() > 0) {
$description .= PHP_EOL . PHP_EOL . 'Customer Chat: ' . PHP_EOL;
foreach ($messages->get() as $message) {
$description .= $message->participant->name . ': ' . $message->message . PHP_EOL;
}
}
if ($activity->summary) {
$description .= PHP_EOL . PHP_EOL . 'Summary: ' . PHP_EOL . $activity->summary;
}
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$description = $activity->description;
break;
}
return $description;
}
public function saveFollowupActivity(Activity $activity, array $fields): ?string
{
// This is the user provided activity subject field.
if (empty($fields['name'])) {
return null;
}
$due = null;
if (empty($fields['due_date']) === false) {
$formatDue = Carbon::parse($fields['due_date']);
$due = $formatDue->toDateTimeString();
}
$clTask = $this->client->post('task', [
'_type' => 'lead',
'lead_id' => $activity->account->crm_provider_id,
'assigned_to' => $this->profile->crm_provider_id,
'text' => $fields['name'],
'date' => $due,
'is_complete' => false,
]);
// We don't actually create a corresponding activity object on our side yet.
return $clTask['id'];
}
/**
* Store transcripts as note.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
if ($activity->account_id === null) {
// We can only log to accounts (leads).
return;
}
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);
$clActivity = $this->client->post('activity/note', [
'lead_id' => $activity->account_id ? $activity->account->crm_provider_id : null,
'created_by' => $this->profile->crm_provider_id,
'user_id' => $this->profile->crm_provider_id,
'note' => $transcripts,
]);
// Store CRM Activity ID in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $clActivity['id'];
$transcription->save();
}
public function parseObjectType(string $objectId): string
{
if (Str::startsWith($objectId, 'lead')) {
return 'account';
}
if (Str::startsWith($objectId, 'cont')) {
return 'contact';
}
if (Str::startsWith($objectId, 'oppo')) {
return 'opportunity';
}
throw new InvalidArgumentException('Unsupported Object Type');
}
/**
* @inheritdoc
*/
public function updateStage($crmObject, Stage $stage): void
{
if ($crmObject instanceof Lead) {
// This would never get invoked since we merge lead/accounts in Close.
$this->client->put('lead/' . $crmObject->crm_provider_id, [
'status' => $stage->crm_provider_id,
]);
} else {
$this->client->put('opportunity/' . $crmObject->crm_provider_id, [
'status_id' => $stage->crm_provider_id,
]);
}
}
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$this->getClient()->updateObject($this->convertObjectTypeToResource($objectType), $objectId, $data);
}
public function prepareValueForUpdate(array $params): array
{
$convertedValue = $this->fieldValueConverter->convertToCrm(
$this->config,
$params['fieldName'],
$params['fieldValue'],
);
if ($this->isCustomField($params['fieldName'])) {
$params['fieldName'] = 'custom.' . $params['fieldName'];
}
$params['fieldValue'] = $convertedValue;
return parent::prepareValueForUpdate($params);
}
public function getRecord(string $objectType, string $objectId, array $fields = []): array
{
return $this->client->get($this->convertObjectTypeToResource($objectType) . '/' . $objectId);
}
/**
*
* @throws UnexpectedValueException
*/
private function convertObjectTypeToResource(string $objectType): string
{
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
return 'opportunity';
case FieldData::OBJECT_CONTACT:
return 'contact';
case FieldData::OBJECT_ACCOUNT:
return 'lead';
case FieldData::OBJECT_TASK:
return 'activity';
default:
throw new UnexpectedValueException('Unsupported object type "' . $objectType . '"');
}
}
public function generateProviderUrl(string $providerId, string $objectType): ?string
{
$baseUrl = 'https://app.close.com/';
$url = null;
switch ($objectType) {
case 'account':
$url = $baseUrl . 'lead/' . $providerId;
break;
case 'contact':
$contact = $this->config->contacts()->where('crm_provider_id', $providerId)->first();
if ($contact && $contact->account_id) {
$url = $baseUrl . 'lead/' . $contact->account->crm_provider_id;
}
break;
default:
// Sadly we can't deeplink to anything else in Close UI.
$url = null;
}
return $url;
}
/**
* Generate transcription for the activity.
*/
private function generateTranscription(Activity $activity): string
{
if (! $this->config->store_transcript) {
// If sending transcription to activity toggle is disabled
return '';
}
return $this->transcriptionService
->findTranscriptionByActivity($activity)
->map(static function (array $transcriptionSegment): string {
return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];
})
->implode(PHP_EOL);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {
try {
$client = $this->getClient();
$task = $client->get('task/' . $crmProviderId);
return ! empty($task);
} catch (HttpNotFoundException) {
// Task not found in CRM - this is expected and permanent
$this->logger->info('[Close] Task not found during verification', [
'task_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
55240
|
1914
|
10
|
2026-05-18T13:58:37.210638+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-18/1779 /Users/lukas/.screenpipe/data/data/2026-05-18/1779112717210_m1.jpg...
|
PhpStorm
|
faVsco.js – Crm/…/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
master, menu
|
[{"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":"master, menu","depth":5,"on_screen":true,"help_text":"Git Branch: master<br/>114 incoming commits<br/>","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7673782238848625796
|
-8646559087753982588
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
master, menu
FirefoxFile• Project: faVsco.js, menu
master, menu
FirefoxFile• 0EditViewHistory→BookmarksProfilesToolsWindowHelpmeet.google.com/cxs-eips-npt?authuser=lukas.kovalik%40jiminny.com[Platform] Refinemen... 2 m left]100% C8 • Mon 18 May 16:58:36)5Galya DimitrovaNikolay YankovNikolay IvanovAneliya AngelovaLukas Kovalik4:58 PM | [Platform] Refinement •1:45:03...
|
55238
|
NULL
|
NULL
|
NULL
|