option('dry-run'); $chunkSize = max(1, (int) $this->option('chunk')); try { $redis = Redis::connection('ami_events'); } catch (\Exception $e) { $this->error('Could not connect to Redis (ami_events): ' . $e->getMessage()); return 1; } // ----------------------------------------------------------------------- // 1. Atomically claim the current batch by renaming the Redis keys. // Any events the Go listener pushes after this point will land in the // freshly-recreated original keys and will be processed on the next run. // ----------------------------------------------------------------------- $tempList = self::LIST_KEY . ':processing'; $tempZSet = self::ZSET_KEY . ':processing'; $listExists = $redis->exists(self::LIST_KEY); if (!$listExists) { $this->info('No events in Redis – nothing to do.'); return 0; } if (!$isDryRun) { // RENAME is atomic; if the target already exists it is overwritten. $redis->rename(self::LIST_KEY, $tempList); if ($redis->exists(self::ZSET_KEY)) { $redis->rename(self::ZSET_KEY, $tempZSet); } } else { // Dry-run: read from the live keys without touching them. $tempList = self::LIST_KEY; $tempZSet = self::ZSET_KEY; } // ----------------------------------------------------------------------- // 2. Read all events from the captured list. // ----------------------------------------------------------------------- $rawEvents = $redis->lrange($tempList, 0, -1); if (empty($rawEvents)) { $this->info('Renamed key is empty – cleaning up.'); if (!$isDryRun) { $redis->del($tempList); } return 0; } $total = count($rawEvents); $this->info("Found {$total} event(s) to process."); // ----------------------------------------------------------------------- // 3. Load extension → user_id mapping for all extensions in this batch. // Primary strategy : extension number == user ID (matches existing // AsteriskEventsHandler behaviour). // Fallback strategy: look up user_extensions.sip_extension. // ----------------------------------------------------------------------- $extensions = []; $parsedEvents = []; // Process in reverse order (oldest first) to give priority to older events $rawEvents = array_reverse($rawEvents); foreach ($rawEvents as $raw) { $event = json_decode($raw, true); if (!is_array($event) || empty($event['extension'])) { continue; } $parsedEvents[] = $event; $extensions[$event['extension']] = true; } $extensionNumbers = array_keys($extensions); // For each extension, generate candidates both with and without the '99' // prefix, since the Go listener may store extensions either way. // Rule: '99' is always a prefix, never part of a real extension number. $extensionCandidates = []; // original ext => [candidate, ...] $allCandidates = []; // flat unique list for DB queries foreach ($extensionNumbers as $ext) { if (substr($ext, 0, 2) === '99' && strlen($ext) > 2) { $cands = [$ext, substr($ext, 2)]; // with & without prefix } else { $cands = [$ext, '99' . $ext]; // without & with prefix } $extensionCandidates[$ext] = $cands; foreach ($cands as $c) { $allCandidates[$c] = true; } } $allCandidates = array_keys($allCandidates); // Build a map: original extension => user_id $extensionToUserId = []; // Primary strategy: candidate number == user ID (matches existing behaviour). $directUserIds = DB::table('users') ->whereIn('id', $allCandidates) ->pluck('id', 'id') // id => id ->toArray(); foreach ($extensionNumbers as $ext) { foreach ($extensionCandidates[$ext] as $cand) { if (isset($directUserIds[$cand])) { $extensionToUserId[$ext] = $directUserIds[$cand]; break; } } } // Fallback strategy: look up user_extensions.sip_extension for any that // still have no match, trying all candidates for each unmapped extension. // $unmapped = array_diff($extensionNumbers, array_keys($extensionToUserId)); // if (!empty($unmapped)) { // $unmappedCandidates = []; // foreach ($unmapped as $ext) { // foreach ($extensionCandidates[$ext] as $cand) { // $unmappedCandidates[$cand] = true; // } // } // $sipRows = DB::table('user_extensions') // ->whereIn('sip_extension', array_keys($unmappedCandidates)) // ->select('user_id', 'sip_extension') // ->get(); // $sipToUserId = []; // foreach ($sipRows as $row) { // $sipToUserId[$row->sip_extension] = $row->user_id; // } // foreach ($unmapped as $ext) { // foreach ($extensionCandidates[$ext] as $cand) { // if (isset($sipToUserId[$cand])) { // $extensionToUserId[$ext] = $sipToUserId[$cand]; // break; // } // } // } // } // ----------------------------------------------------------------------- // 4. Build the rows to insert. // ----------------------------------------------------------------------- $rows = []; $skipped = 0; $now = Carbon::now()->format('Y-m-d H:i:s'); $currentState = []; foreach ($parsedEvents as $event) { if ( in_array($event['action'], ['Hold', 'Unhold']) || !ctype_digit((string) ($event['extension'] ?? '')) ) { continue; } $ext = (string) ($event['extension'] ?? ''); $userId = $extensionToUserId[$ext] ?? null; if ($userId === null) { $skipped++; if ($isDryRun) { $this->warn(" SKIP – no user found for extension: {$ext}"); } continue; } $eventTime = isset($event['datetime']) ? Carbon::parse($event['datetime'])->format('Y-m-d H:i:s') : (isset($event['timestamp']) ? Carbon::createFromTimestamp($event['timestamp'])->format('Y-m-d H:i:s') : $now); $properties = json_encode([ 'channel_type' => $event['channel_type'] ?? null, 'ip_address' => $event['ip_address'] ?? null, ]); $logName = $event['name'] ?? self::LOG_NAME; $lastState = $currentState[$ext] ?? null; if ($event['action'] == 'Reachable') { if ($lastState) { if($lastState['action'] === 'pause') { $event['action'] = 'pause'; $logName = $lastState['log_name']; } } else { $exists = DB::table('attendance') ->where('subject_id', $userId) ->where('description', '!=', 'Unreachable') ->where('created_at', '<=', $eventTime) ->orderByDesc('id') ->first(); if ($exists && $exists->description === 'pause') { $event['action'] = 'pause'; $logName = $exists->log_name; } } } $rows[] = [ 'log_name' => $logName, 'description' => $event['action'], 'subject_id' => $userId, 'subject_type' => self::SUBJECT_TYPE, 'causer_id' => $userId, 'causer_type' => self::SUBJECT_TYPE, 'properties' => $properties, 'created_at' => $eventTime, 'updated_at' => $eventTime, ]; if($event['action'] !== 'Unreachable') { $currentState[$ext] = [ 'action' => $event['action'], 'log_name' => $logName, ]; } } $insertable = count($rows); $this->info("Insertable: {$insertable} | Skipped (no user): {$skipped}"); $descriptionCodes = [ 'LOGGED_IN' => 1, 'Reachable' => 2, 'Unreachable' => 3, 'LOGGED_OUT' => 4, 'unpause' => 5, 'pause' => 6, ]; // Every eligible event built above is recorded here. description_code is // an optional enrichment (nullable in the schema) and never filters // events out. $attendanceRows = array_map(function ($row) use ($descriptionCodes) { return [ 'log_name' => $row['log_name'], 'description' => $row['description'], 'description_code' => $descriptionCodes[$row['description']] ?? null, 'subject_id' => $row['subject_id'], 'created_at' => $row['created_at'], 'updated_at' => $row['updated_at'], ]; }, $rows); if ($isDryRun) { $this->warn('[dry-run] No data written. Redis keys were NOT flushed.'); return 0; } // ----------------------------------------------------------------------- // 5. Bulk-insert into attendance in chunks inside a transaction, so a // failure rolls back the whole batch (Redis keys are kept for the // next run). Attendance events are no longer written to activity_log. // ----------------------------------------------------------------------- if ($insertable > 0) { DB::transaction(function () use ($attendanceRows, $chunkSize) { foreach (array_chunk($attendanceRows, $chunkSize) as $chunk) { DB::table('attendance')->insert($chunk); } }); $this->info("Inserted {$insertable} row(s) into attendance."); } // ----------------------------------------------------------------------- // 6. Clean up the temporary Redis keys (processing batch is done). // ----------------------------------------------------------------------- $redis->del($tempList); if ($redis->exists($tempZSet)) { $redis->del($tempZSet); } $this->info('Redis processing keys flushed. Done.'); return 0; } }