Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1415f5d
Added transcription script
Stell0 Nov 20, 2025
9459975
Merge branch 'main' into strans2
Stell0 Nov 24, 2025
02aeee2
Add mixmonitor in dialplan for call recording and transcription
Stell0 Nov 24, 2025
5bc6250
Merge branch 'main' into strans2
Stell0 Dec 2, 2025
9ddbd53
Merge branch 'main' into strans2
Stell0 Dec 16, 2025
d1739c7
use static translation Satellite branch
Stell0 Dec 16, 2025
eb36f73
feature(satellite): add postgres vectorstore for call transcriptions
Stell0 Dec 18, 2025
0d9d3ab
feature(satellite): add postgres vectorstore for call transcriptions
Stell0 Dec 18, 2025
13bb8b6
fix(satellite): fix pg vectostore init
Stell0 Dec 18, 2025
d6c1d1f
Use development satellite container
Stell0 Dec 19, 2025
1e0f114
Use default satellite container in build
Stell0 Dec 19, 2025
e59cf47
pass speakers name to transcription api
Stell0 Dec 19, 2025
9342b54
Revert "Use development satellite container"
Stell0 Dec 19, 2025
69f7e28
enhancement(ssatellite): better voicemail and transcriptions handling
Stell0 Dec 22, 2025
5ef2743
enhancement(satellite): remove voicemessages_transcriptions schema
Stell0 Dec 22, 2025
224ac81
Summarize voicemail for storage
Stell0 Dec 23, 2025
6ecae88
Merge branch 'main' into strans2
Stell0 Jan 7, 2026
445944e
call mixmonitor for transcription recording before dial instead of af…
Stell0 Jan 7, 2026
adbd51a
Retry failed transcription for 24 hours then erase files
Stell0 Jan 7, 2026
ee600fe
Fix(satellite): fix postgres backup
Stell0 Jan 12, 2026
0f1090a
Satellite: locking and retrying transcription only for 1h
Stell0 Jan 12, 2026
c5ebd8e
Merge branch 'main' into strans2
Stell0 Jan 21, 2026
4141fc8
chores(satellite): use persistent transcription release
Stell0 Jan 22, 2026
ce419f9
fix(services): start/stop services in integrations
Stell0 Jan 22, 2026
63ad583
fix(satellite_pg): backup and restore using dump and not volume
Stell0 Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ COPY imageroot /imageroot
COPY --from=ui_builder /app/dist /ui
ENTRYPOINT [ "/" ]
LABEL org.nethserver.authorizations="traefik@any:fulladm node:fwadm,portsadm nethvoice-proxy@any:routeadm"
LABEL org.nethserver.tcp-ports-demand="36"
LABEL org.nethserver.tcp-ports-demand="37"
LABEL org.nethserver.rootfull="0"
LABEL org.nethserver.min-core="3.6.2-0"
ARG REPOBASE=ghcr.io/nethserver
Expand All @@ -40,4 +40,5 @@ LABEL org.nethserver.images="${REPOBASE}/nethvoice-mariadb:${IMAGETAG} \
${REPOBASE}/nethvoice-reports-api:${IMAGETAG} \
${REPOBASE}/nethvoice-sftp:${IMAGETAG} \
docker.io/library/eclipse-mosquitto:2 \
${REPOBASE}/nethvoice-satellite:${IMAGETAG}"
${REPOBASE}/nethvoice-satellite:${IMAGETAG}\
docker.io/pgvector/pgvector:0.8.1-pg18-trixie"
2 changes: 1 addition & 1 deletion build-images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ images+=("${repobase}/${reponame}")
##########################
echo "[*] Build Satellite container"
reponame="nethvoice-satellite"
container=$(buildah from ghcr.io/nethesis/satellite:0.0.5)
container=$(buildah from ghcr.io/nethesis/satellite:0.0.6)
# Commit the image
buildah commit "${container}" "${repobase}/${reponame}"
buildah commit "${container}" "${repobase}/${reponame}:${IMAGETAG:-latest}"
Expand Down
148 changes: 148 additions & 0 deletions freepbx/var/lib/asterisk/bin/satellite_transcription
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/bin/bash

# Exit immediately if a command exits with a non-zero status.
set -e
# Treat unset variables as an error when substituting.
set -u
# Pipes fail on the first error.
set -o pipefail

require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "Error: Required command not found: $1" >&2
exit 127
}
}

# Function to display usage information
usage() {
cat <<EOF
Usage: $(basename "$0") -u <uniqueid> [-l <language>] [-c0 <name>] [-c1 <name>]

This script calls the get_transcription API with the specified WAV file.

ARGUMENTS:
-u, --uniqueid (Required) The unique ID for the WAV file located at /tmp/satellite-\${UNIQUEID}.wav
-c0, --channel0_name (Optional) Speaker label for channel 0.
-c1, --channel1_name (Optional) Speaker label for channel 1.
-h, --help Show this help.

ENVIRONMENT VARIABLES:
SATELLITE_HTTP_PORT (Required) The port on which the API is listening.
EOF
exit 1
}

# Parse Command-Line Arguments
UNIQUEID=""
CHANNEL0_NAME=""
CHANNEL1_NAME=""

while [[ "$#" -gt 0 ]]; do
case "$1" in
-h|--help)
usage
;;
-u|--uniqueid)
if [[ "$#" -lt 2 ]]; then
echo "Error: --uniqueid requires a value." >&2
usage
fi
UNIQUEID="$2"
shift 2
;;
-c0|--channel0_name)
if [[ "$#" -lt 2 ]]; then
echo "Error: --channel0_name requires a value." >&2
usage
fi
CHANNEL0_NAME="$2"
shift 2
;;
-c1|--channel1_name)
if [[ "$#" -lt 2 ]]; then
echo "Error: --channel1_name requires a value." >&2
usage
fi
CHANNEL1_NAME="$2"
shift 2
;;
*)
echo "Unknown option: $1"
usage
;;
esac
done

# Validate Inputs
if [ -z "${UNIQUEID}" ]; then
echo "Error: --uniqueid is a required argument." >&2
usage
fi

if [ -z "${SATELLITE_HTTP_PORT-}" ]; then
echo "Error: SATELLITE_HTTP_PORT environment variables must be set." >&2
exit 1
fi

if ! [[ "${SATELLITE_HTTP_PORT}" =~ ^[0-9]+$ ]] || (( SATELLITE_HTTP_PORT < 1 || SATELLITE_HTTP_PORT > 65535 )); then
echo "Error: SATELLITE_HTTP_PORT must be an integer between 1 and 65535." >&2
exit 1
fi

require_cmd curl

MAIN_WAV="/var/run/nethvoice/satellite-${UNIQUEID}.wav"
WAV_R="/var/run/nethvoice/satellite-r-${UNIQUEID}.wav"
WAV_T="/var/run/nethvoice/satellite-t-${UNIQUEID}.wav"
LOCK_FILE="/var/run/nethvoice/satellite-transcription-${UNIQUEID}.lock"

# Check for Existing Lock File to Prevent Concurrent Executions or create it using flock
exec 200>"${LOCK_FILE}"
flock -n 200 || {
echo "Error: Another instance of the script is already running for UNIQUEID=${UNIQUEID}." >&2
exit 1
}

# Ensure Cleanup of Lock File on Exit
trap 'rm -f "${LOCK_FILE}"' EXIT TERM INT

if [ ! -f "${MAIN_WAV}" ]; then
if [ ! -f "${WAV_R}" ] || [ ! -f "${WAV_T}" ]; then
echo "Error: Audio file not found at ${MAIN_WAV} and missing one/both legs (${WAV_R}, ${WAV_T})." >&2
exit 1
fi
require_cmd sox
sox -M "${WAV_T}" "${WAV_R}" "${MAIN_WAV}"
fi

# Build and Execute API Call
API_URL="http://127.0.0.1:${SATELLITE_HTTP_PORT}/api/get_transcription"

# Build the curl command arguments in an array for safety and clarity
CURL_ARGS=(
--silent
--show-error
--fail
--request POST
--form "multichannel=true"
--form "encoding=linear16"
--form "sample_rate=8000"
--form "channels=2"
--form "channel0_name=${CHANNEL0_NAME:-Channel 0}"
--form "channel1_name=${CHANNEL1_NAME:-Channel 1}"
--form "persist=true"
--form "summary=true"
--form "uniqueid=${UNIQUEID}"
--form "file=@${MAIN_WAV};type=audio/wav"
)

# Execute the curl command
echo "Sending POST request to ${API_URL}"
if curl "${API_URL}" "${CURL_ARGS[@]}"; then
rm -f -- "${MAIN_WAV}" "${WAV_T}" "${WAV_R}" 2>/dev/null || true
else
echo "Error: Failed to get transcription from the API." >&2
exit 1
fi

63 changes: 40 additions & 23 deletions freepbx/var/lib/asterisk/bin/send_email
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env python3

import time
import os
import sys
import smtplib
Expand Down Expand Up @@ -124,7 +125,7 @@ if 'SATELLITE_VOICEMAIL_TRANSCRIPTION_ENABLED' in os.environ and os.environ['SAT
# Extract the audio data from the wav attachment
audio_data = None
for part in message.walk():
if part.get_content_type() == 'audio/x-wav':
if part.get_content_type() in ('audio/x-wav', 'audio/wav'):
audio_data = part.get_payload(decode=True)
break
if audio_data:
Expand All @@ -136,8 +137,45 @@ if 'SATELLITE_VOICEMAIL_TRANSCRIPTION_ENABLED' in os.environ and os.environ['SAT
# Prepare the file for upload
files = {'file': ('voicemail.wav', audio_data, 'audio/wav')}

# Optional form fields
data = {}

# Query cdrdb to get the call uniqueid
uniqueid = None
try:
conn = get_db_connection()
cursor = conn.cursor()
now = int(time.time())
cursor.execute(
"SELECT uniqueid,cnam,dst_cnam FROM cdr WHERE src = %s AND lastapp = 'VoiceMail' AND uniqueid BETWEEN %s AND %s ORDER BY calldate DESC LIMIT 1",
(message.get('X-Asterisk-CallerID', ''), now - 600, now) # last 10 minutes
)
result = cursor.fetchone()
if result:
uniqueid = result[0]
cnam = result[1]
dst_cnam = result[2]
cursor.close()
conn.close()
except Exception as e:
print(f"Error querying database for uniqueid: {e}", file=sys.stderr)

# If uniqueid exists, enable persistence on satellite
if uniqueid:
data['uniqueid'] = str(uniqueid)
# Save transcription in satellite database
data['persist'] = 'true'
# Store summary in satellite database if OpenAI key is configured
data['summary'] = 'true'
# If cnam is available, set channel0_name
if cnam:
data['channel0_name'] = cnam
# If dst_cnam is available, set channel1_name
if dst_cnam:
data['channel1_name'] = dst_cnam

# Make the API call to satellite
response = requests.post(satellite_url, files=files, timeout=30)
response = requests.post(satellite_url, files=files, data=data, timeout=60)
response.raise_for_status()

# Parse JSON response and extract transcript
Expand All @@ -161,27 +199,6 @@ if 'SATELLITE_VOICEMAIL_TRANSCRIPTION_ENABLED' in os.environ and os.environ['SAT
part.set_charset("UTF-8")
break

# extract the message id
message_id = message.get('Message-ID', '')
if message_id:
# Extract the voicemail ID from the Message-ID
voicemessage_id = message_id.split('-')[1]
# Update the voicemessage table in the database with the transcription
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT IGNORE INTO voicemessages_transcriptions (voicemessage_id,transcription) VALUES (%s, %s)",
(voicemessage_id,transcription_result)
)
conn.commit()
cursor.close()
conn.close()
except mysql.connector.Error as db_error:
print(f"Database error updating transcription: {db_error}", file=sys.stderr)
except Exception as db_error:
print(f"Unexpected database error: {db_error}", file=sys.stderr)

except requests.exceptions.RequestException as e:
print(f"Error transcribing audio: {e}", file=sys.stderr)
except json.JSONDecodeError as e:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,13 +395,17 @@ function nethcti3_get_config_late($engine) {
if (!empty($_ENV['SATELLITE_CALL_TRANSCRIPTION_ENABLED']) && $_ENV['SATELLITE_CALL_TRANSCRIPTION_ENABLED'] == 'True') {
// Add a call Satellite when call is answered in macro-dial-one adding it in D_OPTIONS variable
$ext->splice('macro-dial-one','s','dial', new \ext_setvar('D_OPTIONS', '${D_OPTIONS}U(satellite^s^1)'),'', -1);
// Add mixmonitor to record the call
$ext->splice('macro-dial-one', 's', 'dial', new \ext_mixmonitor('','br(/var/run/nethvoice/satellite-r-${UNIQUEID}.wav)t(/var/run/nethvoice/satellite-t-${UNIQUEID}.wav)','/var/lib/asterisk/bin/satellite_transcription -u ${UNIQUEID} -c0 "${CDR(dst_cnam)}" -c1 "${CDR(cnam)}"'),'', -1);
// Add call to Satellite macro in macro-dialout-trunk if there is at least one route with at least one trunk
$routes = core_routing_list();
if (!empty($routes)) {
foreach (core_routing_list() as $route) {
$routetrunks = core_routing_getroutetrunksbyid($route['route_id']);
if (!empty($routetrunks)) {
$ext->splice('macro-dialout-trunk', 's', '', new \ext_setvar('DIAL_TRUNK_OPTIONS', '${DIAL_TRUNK_OPTIONS}U(satellite^s^1)'),'', 28);
// Add mixmonitor to record the call
$ext->splice('macro-dialout-trunk', 's', '', new \ext_mixmonitor('','br(/var/run/nethvoice/satellite-r-${UNIQUEID}.wav)t(/var/run/nethvoice/satellite-t-${UNIQUEID}.wav)','/var/lib/asterisk/bin/satellite_transcription -u ${UNIQUEID} -c0 "${CDR(dst_cnam)}" -c1 "${CDR(cnam)}"'),'', 28);
break;
}
}
Expand Down
6 changes: 5 additions & 1 deletion imageroot/actions/create-module/05setenvs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ agent.set_env('ASTERISK_RECORDING_SFTP_PORT', port_list[30])
agent.set_env('ASTERISK_WS_PORT', port_list[31])
agent.set_env('SATELLITE_MQTT_PORT', port_list[32])
agent.set_env('SATELLITE_HTTP_PORT', port_list[33])
agent.set_env('SATELLITE_PGSQL_USER', 'satellite')
agent.set_env('SATELLITE_PGSQL_DB', 'satellite')
agent.set_env('SATELLITE_PGSQL_PORT', str(port_list[36]))

# Asterisk WSS
agent.set_env('ASTERISK_WSS_PORT', port_list[34])
Expand Down Expand Up @@ -212,7 +215,8 @@ passwords = {
"REPORTS_API_KEY": gen_password(),
"REPORTS_SECRET": gen_password(),
"SATELLITE_MQTT_PASSWORD": gen_password(),
"SATELLITE_ARI_PASSWORD": gen_password()
"SATELLITE_ARI_PASSWORD": gen_password(),
"SATELLITE_PGSQL_PASSWORD": gen_password()
}

agent.write_envfile("passwords.env", passwords)
2 changes: 2 additions & 0 deletions imageroot/actions/restore-module/20copyenv
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ restore_envs = [
'TIMEZONE',
'TRAEFIK_HTTP2HTTPS',
'USER_DOMAIN',
'SATELLITE_CALL_TRANSCRIPTION_ENABLED',
'SATELLITE_VOICEMAIL_TRANSCRIPTION_ENABLED',
]

for env in restore_envs:
Expand Down
39 changes: 39 additions & 0 deletions imageroot/actions/restore-module/23satellite_pg
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/bash

#
# Copyright (C) 2025 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

set -e -o pipefail
exec 1>&2 # Redirect any output to the journal (stderr)

SATELLITE_PGSQL_PASSWORD=$(grep '^SATELLITE_PGSQL_PASSWORD=' ./passwords.env) && export "${SATELLITE_PGSQL_PASSWORD?}"

mkdir -vp restore
cat - >restore/satellite_postgresql_restore.sh <<'EOS'
# Read dump file from standard input and restore all databases:
psql -U postgres -f /tmp/satellite_postgresql.pg_dump
ec=$?
docker_temp_server_stop
exit $ec
EOS

# Override the image /docker-entrypoint-initdb.d contents, to restore the
# DB dump file. The container will be stopped at the end
podman run \
--rm \
--interactive \
--network=none \
--volume=./restore:/docker-entrypoint-initdb.d/:Z \
--volume=satellite_pgdata:/var/lib/postgresql/data:Z \
--volume=./satellite_postgresql.pg_dump:/tmp/satellite_postgresql.pg_dump:Z \
--replace --name=restore_db \
--env POSTGRES_USER=${SATELLITE_PGSQL_USER} \
--env POSTGRES_PASSWORD=${SATELLITE_PGSQL_PASSWORD} \
--env TZ=UTC \
"${POSTGRES_IMAGE}"


# If the restore is successful, clean up:
rm -rfv restore/ postgresql.pg_dump
12 changes: 12 additions & 0 deletions imageroot/actions/set-integrations/50manage_service
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,22 @@ if call_enabled == 'True' or vm_enabled == 'True':

subprocess.run(["systemctl", "--user", "enable", "satellite.service"], check=True)
subprocess.run(["systemctl", "--user", "restart", "satellite.service"], check=True)

subprocess.run(["systemctl", "--user", "enable", "satellite-pgsql.service"], check=True)
subprocess.run(["systemctl", "--user", "restart", "satellite-pgsql.service"], check=True)

subprocess.run(["systemctl", "--user", "enable", "satellite-recordings-cleanup.service"], check=True)
subprocess.run(["systemctl", "--user", "restart", "satellite-recordings-cleanup.service"], check=True)
elif call_enabled == 'False' and vm_enabled == 'False':
# Stop and disable services
subprocess.run(["systemctl", "--user", "stop", "satellite.service"], check=False)
subprocess.run(["systemctl", "--user", "disable", "satellite.service"], check=False)

subprocess.run(["systemctl", "--user", "stop", "satellite-mqtt.service"], check=False)
subprocess.run(["systemctl", "--user", "disable", "satellite-mqtt.service"], check=False)

subprocess.run(["systemctl", "--user", "stop", "satellite-pgsql.service"], check=False)
subprocess.run(["systemctl", "--user", "disable", "satellite-pgsql.service"], check=False)

subprocess.run(["systemctl", "--user", "stop", "satellite-recordings-cleanup.service"], check=False)
subprocess.run(["systemctl", "--user", "disable", "satellite-recordings-cleanup.service"], check=False)
2 changes: 1 addition & 1 deletion imageroot/bin/module-cleanup-state
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@

set -e

rm -rf db_backup asterisk_backup
rm -rf db_backup asterisk_backup satellite_postgresql.pg_dump
3 changes: 3 additions & 0 deletions imageroot/bin/module-dump-state
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ if podman exec freepbx ls -alh /var/lib/asterisk/sounds/nethcti >/dev/null 2>&1;
podman cp freepbx:/var/lib/asterisk/sounds/nethcti/. asterisk_backup/var/lib/asterisk/sounds/nethcti
fi

# satellite postgresql dump
echo "Dumping Satellite PostgreSQL database"
podman exec satellite-pgsql pg_dumpall -U satellite > satellite_postgresql.pg_dump
1 change: 1 addition & 0 deletions imageroot/etc/state-include.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
state/asterisk_backup
state/db_backup
state/satellite_postgresql.pg_dump
volumes/agi-bin
volumes/asterisk
volumes/lookup.d
Expand Down
Loading
Loading