feat(cti): auto-close post-it on call-ended with duration and listener

This commit is contained in:
michele 2026-03-11 17:59:59 +00:00
parent 4f643c8bef
commit 22be2be817
7 changed files with 300 additions and 8 deletions

View File

@ -96,6 +96,91 @@ public function lookup(Request $request): JsonResponse
]);
}
public function callEnded(Request $request): JsonResponse
{
if (! $this->isAuthorized($request)) {
return response()->json(['ok' => false, 'message' => 'Unauthorized'], 401);
}
$payload = $request->validate([
'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'],
'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'],
'outcome' => ['nullable', 'string', 'max:255'],
'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
'ended_at' => ['nullable', 'date'],
'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'],
'called_extension' => ['nullable', 'string', 'max:30'],
'note' => ['nullable', 'string'],
]);
$normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? ''));
$postIt = $this->findOpenPostIt(
(string) ($payload['event_id'] ?? ''),
$normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '')
);
$created = false;
if (! $postIt && Schema::hasTable('chiamate_post_it')) {
$created = true;
$rubrica = $this->findRubricaByPhone($normalized);
$postIt = ChiamataPostIt::query()->create([
'stabile_id' => null,
'rubrica_id' => $rubrica?->id,
'ticket_id' => null,
'creato_da_user_id' => null,
'telefono' => $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? ''),
'nome_chiamante' => $rubrica?->nome_completo,
'direzione' => $payload['direction'] ?? 'in_arrivo',
'esito' => $payload['outcome'] ?? null,
'origine' => 'panasonic_ns1000',
'origine_id' => $payload['event_id'] ?? null,
'oggetto' => 'Chiamata terminata (evento senza incoming)',
'nota' => $this->buildNote($payload),
'priorita' => 'Media',
'stato' => 'post_it',
'chiamata_il' => $payload['ended_at'] ?? now(),
]);
}
if (! $postIt) {
return response()->json([
'ok' => false,
'message' => 'No matching Post-it and table unavailable',
], 404);
}
$postIt->esito = $payload['outcome'] ?? $postIt->esito;
$postIt->stato = 'chiusa';
if (Schema::hasColumn('chiamate_post_it', 'durata_secondi')) {
$postIt->durata_secondi = $payload['duration_seconds'] ?? $postIt->durata_secondi;
}
if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) {
$postIt->chiusa_il = $payload['ended_at'] ?? now();
}
if (! empty($payload['note'])) {
$existing = trim((string) ($postIt->nota ?? ''));
$suffix = 'Note chiusura PBX: ' . $payload['note'];
$postIt->nota = $existing !== '' ? ($existing . "\n" . $suffix) : $suffix;
}
$postIt->save();
return response()->json([
'ok' => true,
'source' => 'panasonic_ns1000',
'closed' => true,
'created_new' => $created,
'post_it_id' => $postIt->id,
'stato' => $postIt->stato,
'esito' => $postIt->esito,
'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null,
]);
}
private function isAuthorized(Request $request): bool
{
$configured = (string) env('NETGESCON_CTI_SHARED_TOKEN', '');
@ -131,6 +216,43 @@ private function findRubricaByPhone(string $phone): ?RubricaUniversale
->first();
}
private function findOpenPostIt(string $eventId, string $phone): ?ChiamataPostIt
{
if (! Schema::hasTable('chiamate_post_it')) {
return null;
}
if ($eventId !== '') {
$byEvent = ChiamataPostIt::query()
->where('origine', 'panasonic_ns1000')
->where('origine_id', $eventId)
->where('stato', '!=', 'chiusa')
->orderByDesc('id')
->first();
if ($byEvent) {
return $byEvent;
}
}
$digits = PhoneNumber::normalizeDigits($phone);
if ($digits === '') {
return null;
}
$tail = substr($digits, -7);
if ($tail === false || $tail === '') {
return null;
}
return ChiamataPostIt::query()
->where('stato', '!=', 'chiusa')
->where('origine', 'panasonic_ns1000')
->where('telefono', 'like', '%' . $tail . '%')
->orderByDesc('chiamata_il')
->first();
}
private function buildNote(array $payload): string
{
$lines = [

View File

@ -21,15 +21,19 @@ class ChiamataPostIt extends Model
'esito',
'origine',
'origine_id',
'durata_secondi',
'oggetto',
'nota',
'priorita',
'stato',
'chiamata_il',
'chiusa_il',
];
protected $casts = [
'chiamata_il' => 'datetime',
'chiusa_il' => 'datetime',
'durata_secondi' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('chiamate_post_it', function (Blueprint $table): void {
if (! Schema::hasColumn('chiamate_post_it', 'durata_secondi')) {
$table->unsignedInteger('durata_secondi')->nullable()->after('esito');
}
if (! Schema::hasColumn('chiamate_post_it', 'chiusa_il')) {
$table->timestamp('chiusa_il')->nullable()->after('chiamata_il');
}
});
}
public function down(): void
{
Schema::table('chiamate_post_it', function (Blueprint $table): void {
if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) {
$table->dropColumn('chiusa_il');
}
if (Schema::hasColumn('chiamate_post_it', 'durata_secondi')) {
$table->dropColumn('durata_secondi');
}
});
}
};

View File

@ -1,6 +1,6 @@
# CTI Panasonic NS1000 - Setup operativo
Obiettivo: intercettare chiamate in arrivo dal centralino Panasonic NS1000 (CSTA porta 33333) e creare automaticamente Post-it in NetGescon con filtro rubrica.
Obiettivo: intercettare chiamate dal centralino Panasonic NS1000 (CSTA porta 33333) e creare/chiudere automaticamente Post-it in NetGescon con filtro rubrica.
## Prerequisiti verificati
@ -29,12 +29,31 @@ ## 2) Endpoint disponibili
- `POST /api/v1/cti/panasonic/incoming`
- crea Post-it chiamata in `chiamate_post_it`
- fa match automatico numero -> `rubrica_universale`
- `POST /api/v1/cti/panasonic/call-ended`
- chiude automaticamente il Post-it aperto (match su `event_id`, fallback su numero)
- salva `esito`, `durata_secondi`, `chiusa_il`
- `GET /api/v1/cti/panasonic/lookup?phone=...`
- solo lookup rubrica (match yes/no)
Autenticazione: header `X-CTI-Token: <TOKEN>`.
## 3) Test rapido terminale
## 3) Comando server web prova (porta 8000)
```bash
cd /home/michele/netgescon/netgescon-day0
php artisan serve --host=0.0.0.0 --port=8000
```
## 4) Listener terminale per vedere payload grezzi
Metti questo listener in ascolto e punta il PBX a `http://<IP_SERVER_200>:8099` per vedere esattamente i dati in arrivo.
```bash
cd /home/michele/netgescon/netgescon-day0
bash scripts/ops/netgescon-cti-listener.sh --port 8099
```
## 5) Test rapido terminale (incoming + call-ended)
```bash
cd /home/michele/netgescon/netgescon-day0
@ -44,10 +63,12 @@ ## 3) Test rapido terminale
--app-url http://127.0.0.1:8000 \
--token "<TOKEN_LUNGO_RANDOM>" \
--phone "+393331112233" \
--ext "101"
--ext "101" \
--duration 90 \
--outcome "risposta"
```
## 4) Esempio comando da usare lato interfaccia centralino
## 6) Esempio comandi da usare lato interfaccia centralino
Se il centralino puo inviare HTTP su evento incoming call, usa:
@ -72,7 +93,23 @@ ## 4) Esempio comando da usare lato interfaccia centralino
-H "X-CTI-Token: <TOKEN_LUNGO_RANDOM>"
```
## 5) Cosa fa il match numeri
Per evento fine chiamata (chiusura automatica Post-it):
```bash
curl -sS -X POST "http://<IP_NETGESCON>:8000/api/v1/cti/panasonic/call-ended" \
-H "Content-Type: application/json" \
-H "X-CTI-Token: <TOKEN_LUNGO_RANDOM>" \
-d '{
"phone": "<CALLING_NUMBER>",
"event_id": "<PBX_EVENT_ID>",
"outcome": "risposta",
"duration_seconds": 83,
"ended_at": "2026-03-11T10:20:00+01:00",
"note": "evento call-ended NS1000"
}'
```
## 7) Cosa fa il match numeri
- normalizza il numero (es. `+39`, `0039`, spazi, trattini)
- confronta le ultime 7 cifre con:
@ -81,7 +118,26 @@ ## 5) Cosa fa il match numeri
- `telefono_casa`
- `telefono_cellulare_nazionale`
## 6) Step successivo consigliato
## 8) Dev + staging sulla stessa macchina (.200)
- Sviluppo consigliato: `http://127.0.0.1:8000`
- Staging consigliato: `http://127.0.0.1:8001`
Comandi esempio:
```bash
# sviluppo
cd /home/michele/netgescon/netgescon-day0
php artisan serve --host=0.0.0.0 --port=8000
# staging (altro terminale, altra working copy o env staging)
cd /home/michele/netgescon/netgescon-day0
APP_ENV=staging php artisan serve --host=0.0.0.0 --port=8001
```
Nel PBX configura URL diversi per ambiente, mantenendo stesso payload/token o token distinti.
## 9) Step successivo consigliato
1. Agganciare evento reale CSTA incoming.
2. Agganciare evento call-ended per aggiornare `esito` e durata.

View File

@ -82,6 +82,9 @@
Route::post('/incoming', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'incoming'])
->name('api.cti.panasonic.incoming');
Route::post('/call-ended', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'callEnded'])
->name('api.cti.panasonic.call_ended');
Route::get('/lookup', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'lookup'])
->name('api.cti.panasonic.lookup');
});

View File

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
# Minimal raw HTTP listener to inspect payload sent by PBX/webhook tools.
# Use this only for diagnostics in a trusted network.
PORT="8099"
OUT_DIR="storage/logs"
OUT_FILE=""
usage() {
cat <<'EOF'
Usage:
bash scripts/ops/netgescon-cti-listener.sh [options]
Options:
--port <port> Local listen port (default: 8099)
--out <file> Output file (default: storage/logs/cti-listener-<timestamp>.log)
Examples:
bash scripts/ops/netgescon-cti-listener.sh
bash scripts/ops/netgescon-cti-listener.sh --port 8099 --out storage/logs/cti-live.log
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--out) OUT_FILE="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$OUT_FILE" ]]; then
mkdir -p "$OUT_DIR"
OUT_FILE="$OUT_DIR/cti-listener-$(date +%Y%m%d-%H%M%S).log"
fi
if command -v ncat >/dev/null 2>&1; then
echo "[listener] ncat in ascolto su 0.0.0.0:${PORT}"
echo "[listener] output: ${OUT_FILE}"
echo "[listener] stop: Ctrl+C"
ncat -kl "$PORT" | tee -a "$OUT_FILE"
exit 0
fi
if command -v nc >/dev/null 2>&1; then
echo "[listener] nc in ascolto su 0.0.0.0:${PORT}"
echo "[listener] output: ${OUT_FILE}"
echo "[listener] stop: Ctrl+C"
while true; do
nc -l -p "$PORT" | tee -a "$OUT_FILE"
printf '\n----- END REQUEST %s -----\n' "$(date -Iseconds)" | tee -a "$OUT_FILE"
done
fi
echo "Missing netcat tool. Install ncat or nc first." >&2
exit 1

18
scripts/ops/netgescon-panasonic-csta-test.sh Normal file → Executable file
View File

@ -9,6 +9,8 @@ APP_BASE_URL="http://127.0.0.1:8000"
CTI_TOKEN="${NETGESCON_CTI_SHARED_TOKEN:-}"
TEST_PHONE="+393331112233"
TEST_EXT="101"
TEST_DURATION="75"
TEST_OUTCOME="risposta"
usage() {
cat <<'EOF'
@ -22,6 +24,8 @@ Options:
--token <token> Shared token (or env NETGESCON_CTI_SHARED_TOKEN)
--phone <phone> Test caller number
--ext <ext> Test called extension
--duration <sec> Test call duration in seconds (default: 75)
--outcome <text> Test call outcome (default: risposta)
Example:
bash scripts/ops/netgescon-panasonic-csta-test.sh --token 'my-secret-token'
@ -36,6 +40,8 @@ while [[ $# -gt 0 ]]; do
--token) CTI_TOKEN="$2"; shift 2 ;;
--phone) TEST_PHONE="$2"; shift 2 ;;
--ext) TEST_EXT="$2"; shift 2 ;;
--duration) TEST_DURATION="$2"; shift 2 ;;
--outcome) TEST_OUTCOME="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
@ -53,10 +59,18 @@ echo "[2/3] Lookup numero in rubrica"
curl -sS "${APP_BASE_URL%/}/api/v1/cti/panasonic/lookup?phone=${TEST_PHONE}" \
-H "X-CTI-Token: ${CTI_TOKEN}" | sed -n '1,120p'
echo "[3/3] Simulazione evento chiamata in arrivo -> Post-it"
EVENT_ID="test-$(date +%s)"
echo "[3/4] Simulazione evento chiamata in arrivo -> Post-it"
curl -sS -X POST "${APP_BASE_URL%/}/api/v1/cti/panasonic/incoming" \
-H "Content-Type: application/json" \
-H "X-CTI-Token: ${CTI_TOKEN}" \
-d "{\"phone\":\"${TEST_PHONE}\",\"direction\":\"in_arrivo\",\"called_extension\":\"${TEST_EXT}\",\"event_id\":\"test-$(date +%s)\",\"note\":\"test webhook panasonic\"}" | sed -n '1,160p'
-d "{\"phone\":\"${TEST_PHONE}\",\"direction\":\"in_arrivo\",\"called_extension\":\"${TEST_EXT}\",\"event_id\":\"${EVENT_ID}\",\"note\":\"test webhook panasonic incoming\"}" | sed -n '1,160p'
echo "[4/4] Simulazione evento call-ended -> chiusura automatica Post-it"
curl -sS -X POST "${APP_BASE_URL%/}/api/v1/cti/panasonic/call-ended" \
-H "Content-Type: application/json" \
-H "X-CTI-Token: ${CTI_TOKEN}" \
-d "{\"phone\":\"${TEST_PHONE}\",\"event_id\":\"${EVENT_ID}\",\"outcome\":\"${TEST_OUTCOME}\",\"duration_seconds\":${TEST_DURATION},\"ended_at\":\"$(date -Iseconds)\",\"note\":\"test webhook panasonic call-ended\"}" | sed -n '1,200p'
echo "Done"