#!/usr/bin/php
<?php

# Invoked as a MixMonitor post-process command (see [sub-call-transcription]
# in extensions__20-baseplan.conf and transcriptFunctions.php, which builds
# this command line) once transcription audio capture finishes, whether via
# StopMixMonitor or a normal hangup. Args use MixMonitor's ^{VAR} syntax so
# that they survive being stored in a global, but MixMonitor expands them when
# it is called, at the start of the capture, not when it finishes: "All
# variables will be evaluated at the time MixMonitor is called" (core show
# application MixMonitor). Nothing passed here can describe how the call
# ended, which is why the duration is measured from the audio instead.
#
# Hands the finished caller/callee files to CPBX-AI by renaming them in place,
# which is all the handover consists of: CPBX-AI scans this directory and picks
# up anything carrying the ready prefix.
#
# Nothing is copied and no request is made. The rename is atomic, so a file
# with the prefix is always a complete capture, and a capture that CPBX-AI
# misses is simply found on its next scan instead of being lost. From that
# point the file is CPBX-AI's: it transcribes it and deletes it.
#
# The name carries what the job needs, because it is the only description of
# the work that survives the handover:
#
#   ready_<callid>_<duration>_<role>.<format>
#
# Three files per call: the mix, which is the one transcribed, and the two
# legs, which stay on the PBX and are only used to tell which speaker was the
# caller.

# Everything below arrives on a command line and nothing downstream questions
# it: hand_over() renames what it is handed and discard_audio() unlinks it.
# Asterisk runs this as the asterisk user, which can write across
# /var/spool/asterisk - every recording and voicemail message on the box - so
# an argument is only acted on once it has been shown to name a capture
# (XOR-13589). The directory is the same constant repcdrFunctions.php uses.
define("TRANSCRIPTION_CAPTURE_DIR", "/var/spool/asterisk/transcription-tmp");

$call = (object) [
	"caller_file" => "",
	"callee_file" => "",
	"mix_file" => "",
	"uniqueid" => "",
	"linkedid" => "",
	"duration" => "",
];

try {
	parse_cmdline($argv, $call);
	// A call that was never answered has no conversation in it. MixMonitor
	// still creates the three files, but the 'b' option means it only records
	// while the channel is bridged, so they hold a container header and no
	// samples. Submitting those spends Soniox calls and a licensing
	// reservation to be told there is no speech, and leaves a failed job the
	// user can neither fix nor retry.
	//
	// The captured audio is the only reliable way to know this. CDR(billsec)
	// looks like a cheaper signal and is not: it reads 0 on both halves of a
	// Local channel even for a fully answered call, so queues, ring groups
	// and follow-me would silently throw away real conversations.
	if (!has_audio($call->caller_file) && !has_audio($call->callee_file)) {
		discard_audio($call);
	} else {
		// Always the audio, never an argument. A duration handed in by the
		// dialplan was read when the capture started, so it only came out
		// right by accident: 0 on a call captured before it was answered,
		// which fell through to this measurement, and the seconds already
		// elapsed on one captured after, which did not (XOR-13541). A PBX
		// whose dialplan predates this still passes one; it is ignored.
		$call->duration = captured_duration([$call->caller_file, $call->callee_file]);
		hand_over($call);
	}
} catch (Exception $e) {
	log_error($call, $e->getMessage());
}

function parse_cmdline($argv, &$call) {
	foreach (array_slice($argv, 1) as $arg) {
		if (strpos($arg, "=") === false) {
			continue;
		}
		list($name, $val) = explode("=", $arg, 2);
		// Only the fields declared above. A dialplan older than XOR-13541
		// still passes duration=, which is ignored rather than refused so
		// that a PBX upgraded mid-call still hands its captures over.
		if (!property_exists($call, $name)) {
			continue;
		}
		$call->$name = $val;
	}
	foreach (["caller_file", "callee_file", "uniqueid"] as $key) {
		if ($call->$key == "") {
			throw new Exception("Missing command-line argument: $key=");
		}
	}
	// The ids name the file this renames to, so they decide where the rename
	// lands. Pinned to the shape of an Asterisk id ("<epoch>.<sequence>"):
	// with no slash in it, an id cannot walk the target out of the capture
	// directory, which is what the check below would otherwise only half
	// guarantee.
	foreach (["uniqueid", "linkedid"] as $key) {
		if ($call->$key != "" && !preg_match('/^[A-Za-z0-9._-]+$/', $call->$key)) {
			throw new Exception("Refusing {$key}={$call->$key}: not an Asterisk call id");
		}
	}
	// A path that does not name a capture is dropped rather than refused:
	// every function below already reads "" as "nothing to do", so the rest
	// of a legitimate call still goes through, and a crafted argument simply
	// stops existing. Logged, because a real capture being dropped here would
	// otherwise be a transcript that quietly never appears.
	foreach (["caller_file", "callee_file", "mix_file"] as $key) {
		if ($call->$key == "") {
			continue;
		}
		$canonical = capture_path($call->$key);
		if ($canonical === false) {
			log_error($call, "Ignoring {$key}={$call->$key}: not a capture in " . TRANSCRIPTION_CAPTURE_DIR);
		}
		// The resolved path, not the one handed in. A symlink that resolves
		// into the directory passes the check, but renaming or unlinking it
		// would act on the link where it sits rather than on the capture -
		// and hand_over() builds its target from the path's own directory.
		$call->$key = $canonical === false ? "" : $canonical;
	}
	if ($call->caller_file == "" && $call->callee_file == "" && $call->mix_file == "") {
		throw new Exception("No capture files left to act on");
	}
}

// The capture a path names, resolved, or false when it names anything else.
//
// realpath() is what decides it - it resolves symlinks and any ".." along the
// way, so a link planted inside the directory cannot aim the rename or the
// unlink at something outside it. It also means a path that does not exist is
// not a capture, which is correct here: there is nothing to hand over or
// discard either way.
//
// Only .wav, matched case-insensitively. The dialplan has written lower case
// "wav" since XOR-13541, but a PBX upgraded with captures already on disk can
// still hold a ".WAV" from before, and those are real audio owed a real
// transcript.
function capture_path($path) {
	if (strtolower(substr($path, -4)) !== ".wav") {
		return false;
	}
	$dir = realpath(TRANSCRIPTION_CAPTURE_DIR);
	$real = realpath($path);
	if ($dir === false || $real === false || dirname($real) !== $dir) {
		return false;
	}
	return $real;
}

// Renames all three captures so CPBX-AI's scan will pick them up.
//
// The mix is what gets transcribed: one track holding both parties, which
// costs half of what two separate submissions cost and transcribes better,
// because the model can hear the whole conversation rather than one side of
// it. The two legs are handed over as well, but they are never sent anywhere
// -- CPBX-AI compares their loudness to work out which diarized speaker was
// the caller, which is the one thing a single mixed track cannot say.
//
// A capture that cannot be renamed is left alone rather than deleted: it is
// the only copy of that audio, and leaving it costs a stale file whereas
// deleting it loses the call.
function hand_over($call) {
	$call_id = $call->linkedid != "" ? $call->linkedid : $call->uniqueid;
	$duration = (int) $call->duration;

	// The mix goes last, and that ordering is the whole handover protocol.
	// CPBX-AI scans this directory on its own clock, so it can look in the
	// middle of these renames and see a half-handed-over call. The mix is the
	// file it waits for: while the mix is still under its working name there
	// is nothing to pick up, and by the time it appears the legs are already
	// there under theirs. Renaming the mix first - as this did - let a scan
	// land between the first rename and the second and take the call with no
	// legs at all, which transcribes fine and then has no way to tell which
	// speaker was the caller.
	foreach (["caller_file" => "caller", "callee_file" => "callee", "mix_file" => "mix"] as $key => $role) {
		$path = $call->$key;
		if ($path == "" || !is_file($path)) {
			continue;
		}
		$dir = dirname($path);
		$format = pathinfo($path, PATHINFO_EXTENSION);
		$target = sprintf("%s/ready_%s_%d_%s.%s", $dir, $call_id, $duration, $role, $format);
		if (!@rename($path, $target)) {
			log_error($call, "Could not hand over {$path}");
		}
	}
}

// Reads a RIFF/WAVE capture and returns [audio bytes, bytes per second], or
// null when the file is not WAVE or cannot be parsed.
//
// The size on disk is not enough on its own: a capture with no samples is
// still a valid 44-byte file, and Asterisk may leave the declared chunk
// length at zero, so the data chunk is walked explicitly and its real payload
// measured.
function wav_audio_info($path) {
	$size = filesize($path);
	if ($size === false || $size < 12) {
		return null;
	}
	$handle = @fopen($path, "rb");
	if ($handle === false) {
		return null;
	}
	$riff = fread($handle, 12);
	if (strlen($riff) < 12 || substr($riff, 0, 4) !== "RIFF" || substr($riff, 8, 4) !== "WAVE") {
		fclose($handle);
		return null;
	}

	$byte_rate = 0;
	while (($header = fread($handle, 8)) !== false && strlen($header) == 8) {
		$chunk = unpack("Vsize", substr($header, 4, 4));
		$id = substr($header, 0, 4);
		$chunk_size = $chunk["size"];
		if ($id === "fmt " && $chunk_size >= 16) {
			$fmt = fread($handle, $chunk_size);
			if (strlen($fmt) >= 16) {
				$parsed = unpack("Vrate", substr($fmt, 8, 4));
				$byte_rate = $parsed["rate"];
			}
			continue;
		}
		if ($id === "data") {
			// Trust whichever is smaller: a declared length of 0 on a file
			// that still has bytes after the header means Asterisk never
			// updated it, and vice versa.
			$remaining = $size - ftell($handle);
			$bytes = $chunk_size > 0 ? min($chunk_size, $remaining) : $remaining;
			fclose($handle);
			return [max(0, $bytes), $byte_rate];
		}
		if (fseek($handle, $chunk_size + ($chunk_size % 2), SEEK_CUR) !== 0) {
			break;
		}
	}
	fclose($handle);
	return null;
}

// True when the file holds at least one audio sample. The fallback to "the
// file is not empty" covers a capture whose RIFF headers cannot be parsed;
// the container itself is always WAVE now, since capture_path() admits
// nothing else and the dialplan writes nothing else (XOR-13541).
function has_audio($path) {
	if ($path == "" || !is_file($path)) {
		return false;
	}
	$info = wav_audio_info($path);
	if (is_null($info)) {
		return filesize($path) > 0;
	}
	return $info[0] > 0;
}

// Seconds of conversation captured, taken from the longest leg.
//
// This is the only duration the job is admitted with. The captured audio
// starts and stops with the bridge, so its length is the conversation that
// will be transcribed, and it is the same file Soniox will measure; CPBX-AI
// replaces this with Soniox's figure once it has one, since that is what is
// billed.
function captured_duration($paths) {
	$longest = 0;
	foreach ($paths as $path) {
		if ($path == "" || !is_file($path)) {
			continue;
		}
		$info = wav_audio_info($path);
		if (is_null($info) || $info[1] <= 0) {
			continue;
		}
		$seconds = (int) round($info[0] / $info[1]);
		if ($seconds > $longest) {
			$longest = $seconds;
		}
	}
	return $longest;
}

// Removes the captured audio. Only called when it is known to be worthless:
// a call that was never bridged produces files holding a container header and
// no samples, and no amount of retrying will make those transcribable.
function discard_audio($call) {
	foreach (["caller_file", "callee_file", "mix_file"] as $key) {
		$path = $call->$key;
		if ($path != "" && file_exists($path) && !@unlink($path)) {
			log_error($call, "Could not remove transcription audio {$path}");
		}
	}
}

// Suppressed, and syslog second: this runs as the handler of an error that
// already happened, and cli.php turns warnings into exceptions. An unwritable
// log file would otherwise raise here and replace a logged failure with an
// uncaught fatal, losing the original error entirely. syslog is the copy that
// survives that case.
function log_error($call, $msg) {
	@file_put_contents(
		"/var/log/cpbx/transcription-submit.log",
		date("c") . " {$msg} (uniqueid={$call->uniqueid})\n",
		FILE_APPEND);
	openlog("transcription-submit", LOG_PID, LOG_LOCAL1);
	syslog(LOG_WARNING, "{$msg} (uniqueid={$call->uniqueid})");
}

?>
