ping()) return $db; $db=new mysqli( $config['db_host'], $config['db_user'], $config['db_pass'], $config['db_name'] ); $db->set_charset($config['db_charset'] ?? 'utf8mb4'); return $db; } /* ============================================================ WS PUSH ============================================================ */ function wsPush(array $d){ global $config; $s=@stream_socket_client( "tcp://{$config['ws_bridge_host']}:{$config['ws_bridge_port']}", $e,$r,1 ); if($s){ fwrite($s,json_encode($d,JSON_UNESCAPED_UNICODE)."\n"); fclose($s); } } /* ============================================================ HELPERS ============================================================ */ function numFrom(EventMessage $e){ foreach(['CallerIDNum','ConnectedLineNum','CallerID','DialString'] as $k){ $v=$e->getKey($k); if($v && preg_match('/(\+?\d{7,15})/',$v,$m)) return $m[1]; } return ''; } function extFrom(EventMessage $e){ $c=$e->getKey('Channel') ?? ''; return preg_match('/PJSIP\/(\d+)-/',$c,$m)?$m[1]:''; } function extFromDest($dest){ return preg_match('/PJSIP\/(\d+)-/',$dest,$m) ? $m[1] : ''; } function isExternalNumber($n){ return $n && strlen(preg_replace('/\D/','',$n))>=7; } function isOutbound(EventMessage $e){ $ctx=$e->getKey('Context') ?? ''; return stripos($ctx,'from-internal')!==false; } /* ============================================================ CONTACT LOOKUP ============================================================ */ function lookupContact($num){ if(!$num) return null; $db=db(); $n=preg_replace('/\D/','',$num); if(!$n) return null; $r=$db->query(" SELECT cd.contactid, CONCAT(cd.firstname,' ',cd.lastname) AS name, acc.accountname AS organization, ( SELECT COUNT(*) FROM vtiger_troubletickets tt WHERE tt.contact_id=cd.contactid AND tt.status!='Closed' ) AS openTickets FROM vtiger_contactdetails cd LEFT JOIN vtiger_account acc ON acc.accountid=cd.accountid WHERE REPLACE(cd.phone,' ','') LIKE '%$n' OR REPLACE(cd.mobile,' ','') LIKE '%$n' LIMIT 1 "); return $r?$r->fetch_assoc():null; } /* ============================================================ CRM SEQUENCER (UNCHANGED) ============================================================ */ function nextCrmIdSafe(){ $db=db(); $db->begin_transaction(); try{ $db->query("SELECT id FROM vtiger_crmentity_seq FOR UPDATE"); $r=$db->query(" SELECT MIN(t1.crmid+1) AS next_id FROM vtiger_crmentity t1 LEFT JOIN vtiger_crmentity t2 ON t1.crmid+1=t2.crmid WHERE t2.crmid IS NULL "); $id=(int)($r->fetch_assoc()['next_id']??1); $db->query("UPDATE vtiger_crmentity_seq SET id=".($id+1)); $db->commit(); return $id; }catch(Throwable $e){ $db->rollback(); throw $e; } } /* ============================================================ CALL STATE ============================================================ */ $calls=[]; function initCall($uuid){ global $calls; if(isset($calls[$uuid])) return; $calls[$uuid]=[ 'num'=>'', 'answered'=>false, 'answered_ext'=>'', 'answered_chan'=>'', 'start'=>time(), 'finalized'=>false, 'stage'=>0, 'last_dial_time'=>0, 'last_target'=>'', 'missed_candidate_at'=>0 ]; L("INIT $uuid"); } /* ============================================================ FINALIZE ============================================================ */ function finalize($uuid,$reason){ global $calls; if(!isset($calls[$uuid])) return; $c=$calls[$uuid]; $db=db(); if(empty($c['num'])){ unset($calls[$uuid]); return; } if($reason==='missed' && $c['answered']) $reason='completed'; if($c['finalized']) return; $calls[$uuid]['finalized']=true; $owner=1; if($c['answered_ext']){ $ext=$db->real_escape_string($c['answered_ext']); $r=$db->query("SELECT id FROM vtiger_users WHERE phone_crm_extension='$ext'"); if($r && $u=$r->fetch_assoc()) $owner=(int)$u['id']; } $id=nextCrmIdSafe(); $now=date('Y-m-d H:i:s'); $dur=max(1,time()-$c['start']); $num=$db->real_escape_string($c['num']); $db->query(" INSERT INTO vtiger_crmentity (crmid,smcreatorid,smownerid,modifiedby,setype,createdtime,modifiedtime,label) VALUES ($id,$owner,$owner,$owner,'PBXManager','$now','$now','".ucfirst($reason)."') "); $db->query(" INSERT INTO vtiger_pbxmanager (pbxmanagerid,direction,callstatus,starttime,endtime,totalduration,sourceuuid,customernumber,user) VALUES ($id,'inbound','$reason','".date('Y-m-d H:i:s',$c['start'])."','$now',$dur,'$uuid','$num',$owner) "); wsPush([ 'event'=>'call_end', 'uuid'=>$uuid, 'type'=>"inbound_$reason", 'number'=>$c['num'], 'answeredBy'=>$c['answered_ext']?:null ]); L("FINAL $reason uuid=$uuid num={$c['num']} id=$id"); unset($calls[$uuid]); } /* ============================================================ AMI CLIENT ============================================================ */ $ami=new ClientImpl([ 'host'=>$config['ami_host'], 'port'=>$config['ami_port'], 'username'=>$config['ami_user'], 'secret'=>$config['ami_secret'], 'scheme'=>'tcp://', 'connect_timeout'=>10, 'read_timeout'=>30, ]); /* ============================================================ EVENTS ============================================================ */ /* FIRST RING — init only (NO POPUP HERE) */ $ami->registerEventListener(function(EventMessage $e){ if($e->getName()!=='Newchannel') return; $uuid=$e->getKey('Linkedid')?:$e->getKey('Uniqueid'); if(!$uuid) return; $num=numFrom($e); if(!isExternalNumber($num)) return; if(isOutbound($e)) return; initCall($uuid); $GLOBALS['calls'][$uuid]['missed_candidate_at']=0; if($GLOBALS['calls'][$uuid]['num']) return; $GLOBALS['calls'][$uuid]['num']=$num; $GLOBALS['calls'][$uuid]['stage']=1; L("INIT RING stage=1 uuid=$uuid"); }); /* DIAL BEGIN — FAZA 1 per-agent popup */ $ami->registerEventListener(function(EventMessage $e){ if($e->getName()!=='DialBegin') return; $uuid=$e->getKey('Linkedid')?:$e->getKey('Uniqueid'); if(!$uuid || !isset($GLOBALS['calls'][$uuid])) return; if($GLOBALS['calls'][$uuid]['answered']) return; $dest=$e->getKey('DestChannel') ?: $e->getKey('DialString'); if(!$dest) return; if($GLOBALS['calls'][$uuid]['last_target']===$dest) return; $ext = extFromDest($dest); if(!$ext) return; $GLOBALS['calls'][$uuid]['last_target']=$dest; $GLOBALS['calls'][$uuid]['last_dial_time']=time(); $GLOBALS['calls'][$uuid]['stage']++; $GLOBALS['calls'][$uuid]['missed_candidate_at']=0; $num=$GLOBALS['calls'][$uuid]['num']; $c=lookupContact($num); wsPush([ 'event' => 'ring', 'uuid' => $uuid, 'extension' => $ext, 'number' => $num, 'name' => $c['name']??'', 'organization'=>$c['organization']??'', 'openTickets'=>(int)($c['openTickets']??0) ]); L("RING ext=$ext stage={$GLOBALS['calls'][$uuid]['stage']} uuid=$uuid"); }); /* ANSWER */ $ami->registerEventListener(function(EventMessage $e){ if($e->getName()!=='Newstate') return; if($e->getKey('ChannelStateDesc')!=='Up') return; $uuid=$e->getKey('Linkedid')?:$e->getKey('Uniqueid'); if(!$uuid || !isset($GLOBALS['calls'][$uuid])) return; $ext=extFrom($e); if(!$ext) return; $GLOBALS['calls'][$uuid]['answered']=true; $GLOBALS['calls'][$uuid]['answered_ext']=$ext; $GLOBALS['calls'][$uuid]['answered_chan']=$e->getKey('Channel'); $GLOBALS['calls'][$uuid]['missed_candidate_at']=0; wsPush(['event'=>'answered','uuid'=>$uuid,'extension'=>$ext]); L("ANSWER uuid=$uuid ext=$ext"); }); /* BRIDGE LEAVE */ $ami->registerEventListener(function(EventMessage $e){ if($e->getName()!=='BridgeLeave') return; $uuid=$e->getKey('Linkedid')?:$e->getKey('Uniqueid'); if(!$uuid || !isset($GLOBALS['calls'][$uuid])) return; if($e->getKey('Channel')===$GLOBALS['calls'][$uuid]['answered_chan']){ finalize($uuid,'completed'); } }); /* DIAL END → MISSED CANDIDATE */ $ami->registerEventListener(function(EventMessage $e){ if($e->getName()!=='DialEnd') return; $uuid=$e->getKey('Linkedid')?:$e->getKey('Uniqueid'); if(!$uuid || !isset($GLOBALS['calls'][$uuid])) return; if($GLOBALS['calls'][$uuid]['answered']) return; $status=$e->getKey('DialStatus'); if(!in_array($status,['NOANSWER','BUSY','CANCEL','CHANUNAVAIL'])) return; $GLOBALS['calls'][$uuid]['missed_candidate_at']=time()+3; L("MISSED CANDIDATE uuid=$uuid"); }); /* ============================================================ MAIN LOOP ============================================================ */ L("Listener v7.1.9 FAZA1 per-agent popup READY"); while(true){ try{ $ami->open(); L("AMI connected"); while(true){ $ami->process(); usleep(10000); $now=time(); foreach($calls as $uuid=>$c){ if($c['finalized']) continue; if($c['answered']) continue; if(!empty($c['missed_candidate_at']) && $c['missed_candidate_at'] <= $now){ finalize($uuid,'missed'); } } } }catch(Throwable $e){ L("ERROR ".$e->getMessage()); try{$ami->close();}catch(Throwable $x){} sleep(5); } }