mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-15 17:56:12 +02:00
feat(telefon): Outbound-ESL-Bruecke fuer Live-Pipecat-Gespraeche
Bruecke zwischen FreeSWITCH/mod_audio_stream und dem neuen Astra-Telefonie- Endpunkt (/telefon/inbound) - erlaubt echte, live gefuehrte Gespraeche statt nur Aufnahme+Nachbearbeitung: - dialog/pipecat_bootstrap.py: nimmt Kanaele per socket() entgegen (Nebenstelle 7501 intern, oder von dialog/anrufen.py ausgehend), startet uuid_audio_stream und haengt fuer die gesamte Gespraechsdauer auf der Leitung, um mod_audio_stream::play-Events abzuspielen (playback() - mod_audio_stream schreibt nur eine Datei und feuert ein Event, spielt selbst nichts ab). uuid_audio_stream laeuft bewusst ueber einen separaten fs_cli-Prozess, nicht ueber die eigene ESL-Verbindung (siehe Kommentar im Modul - sonst schlaegt der WebSocket-Connect bei echten externen Anrufen zuverlaessig fehl, amigniter/mod_audio_stream#92). - dialog/anrufen.py: loest ausgehende Anrufe aus (python3 -m dialog.anrufen <Nummer>), haengt sie an dieselbe Bruecke. - .env.example, requirements.txt (greenswitch), telefon/starten.sh: Bootstrap als vierten Hintergrunddienst mitstarten, wenn ASTRA_TELEFON_WS_URL gesetzt ist. mod_audio_stream selbst (gebaut mit -DUSE_TLS=ON) und die FreeSWITCH-Gateway- Konfiguration (Domain/Proxy-Trennung fuer Outbound, siehe agentzwei_sbc.xml) liegen direkt in der FreeSWITCH-Konfiguration, nicht in diesem Repo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfxZnUC9dZ8QT36pkfEPmM
This commit is contained in:
parent
3da0cd7ec9
commit
394a0faa27
6 changed files with 275 additions and 0 deletions
|
|
@ -128,3 +128,9 @@ FS_ETC=/opt/homebrew/etc/freeswitch
|
|||
# Log von FreeSWITCH; der Leitstand liest daraus die Anruf-Ereignisse.
|
||||
# Apple Silicon: /opt/homebrew/... , Intel-Mac: /usr/local/...
|
||||
#FREESWITCH_LOG=/opt/homebrew/var/log/freeswitch/freeswitch.log
|
||||
|
||||
# Pipecat-Testleitung (dialog/pipecat_bootstrap.py, Nebenstelle 7501) -
|
||||
# WebSocket-Ziel fuer mod_audio_stream. token= muss ASTRA_TELEFON_SECRET
|
||||
# auf der Astra-Maschine entsprechen.
|
||||
ASTRA_TELEFON_WS_URL=wss://astra-host.example.ts.net/telefon/inbound?token=changeme
|
||||
PIPECAT_BOOTSTRAP_PORT=8095
|
||||
|
|
|
|||
0
dialog/__init__.py
Normal file
0
dialog/__init__.py
Normal file
68
dialog/anrufen.py
Normal file
68
dialog/anrufen.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Outbound-Call-Trigger fuer die Pipecat-Testleitung.
|
||||
|
||||
Ruft eine Nummer ueber den Plusnet-Trunk an und uebergibt sie nach Abheben an
|
||||
dieselbe Bruecke wie die interne Testnebenstelle 7501
|
||||
(dialog/pipecat_bootstrap.py, Port 8095) - answer()+uuid_audio_stream+park()
|
||||
laeuft dort identisch, unabhaengig davon ob der Anruf rein- oder rausgeht. Der
|
||||
Dialplan-Umweg (Nebenstelle 7501) entfaellt hier: `&socket(...)` haengt den
|
||||
ausgehenden Kanal direkt nach Abheben an den Bootstrap, ohne Dialplan-Lookup.
|
||||
|
||||
Nutzung: python3 -m dialog.anrufen <Nummer>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import greenswitch
|
||||
|
||||
FS_HOST = "127.0.0.1"
|
||||
FS_PORT = int(os.environ.get("FS_PORT", "8022"))
|
||||
FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon")
|
||||
BOOTSTRAP_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095"))
|
||||
# DW9 ("rufagent") - the only account confirmed capable of outbound so far.
|
||||
# Needs "proxy" (R-URI domain, must be sip.plusnet.de - Plusnet 403s otherwise)
|
||||
# split from "outbound-proxy" (actual packet routing, sbc.sip.plusnet.de) in
|
||||
# its gateway XML - see sip_profiles/external/agentzwei_sbc.xml.
|
||||
GATEWAY = os.environ.get("ANRUF_GATEWAY", "agentzwei_sbc")
|
||||
CALLER_ID = os.environ.get("ANRUF_CALLER_ID", "+49210378916179")
|
||||
|
||||
|
||||
def normalisiere(nummer: str) -> str:
|
||||
"""Wie pipe/kontakte.py: nur Ziffern, Deutschland-Annahme 0 -> 49,
|
||||
kein '+' - so erwartet es der Trunk auch fuer DW3_DDI in der .env."""
|
||||
ziffern = "".join(zeichen for zeichen in nummer if zeichen.isdigit() or zeichen == "+")
|
||||
if ziffern.startswith("+"):
|
||||
return ziffern[1:]
|
||||
if ziffern.startswith("00"):
|
||||
return ziffern[2:]
|
||||
if ziffern.startswith("0"):
|
||||
return "49" + ziffern[1:]
|
||||
return ziffern
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("Nutzung: python3 -m dialog.anrufen <Nummer>")
|
||||
ziel = normalisiere(sys.argv[1])
|
||||
# The &socket(...) part MUST stay in single quotes: originate splits its
|
||||
# arguments on spaces, so an unquoted "&socket(127.0.0.1:8095 async full)"
|
||||
# reaches the channel as plain "socket(127.0.0.1:8095)" - "async full"
|
||||
# silently dropped (seen in freeswitch.log's own EXECUTE line, 2026-09-13).
|
||||
# Without "full", mod_event_socket refuses every command past sendmsg on
|
||||
# an outbound listener, so pipecat_bootstrap's CUSTOM-event subscription
|
||||
# ("event plain CUSTOM mod_audio_stream::play") is answered with
|
||||
# "-ERR command not found" and no audio is ever played back.
|
||||
befehl = (
|
||||
f"originate {{origination_caller_id_number={CALLER_ID}}}"
|
||||
f"sofia/gateway/{GATEWAY}/{ziel} "
|
||||
f"'&socket({FS_HOST}:{BOOTSTRAP_PORT} async full)'"
|
||||
)
|
||||
print(f"Rufe {ziel} an ueber: {befehl}")
|
||||
fs = greenswitch.InboundESL(host=FS_HOST, port=FS_PORT, password=FS_ESL_PASSWORD)
|
||||
fs.connect()
|
||||
antwort = fs.send(f"api {befehl}")
|
||||
print(antwort.data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
187
dialog/pipecat_bootstrap.py
Normal file
187
dialog/pipecat_bootstrap.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Outbound-ESL bootstrap for the Pipecat phone-agent test line.
|
||||
|
||||
FreeSWITCH's dialplan extension "agentzwei_pipecat_test" (destination_number
|
||||
7501, dialplan/default/06_agentzwei_pipecat_test.xml, internal test-only, not
|
||||
reachable from the PSTN) - and now also dialog/anrufen.py's outbound calls -
|
||||
hand the channel here via `socket(127.0.0.1:PORT async full)`. This process's
|
||||
job: answer(), tell mod_audio_stream to start streaming the call's audio to
|
||||
Astra's /telefon/inbound, then stay on the line for as long as the call lasts
|
||||
to actually play back what Astra sends - the conversation logic itself lives
|
||||
entirely in Astra's Pipecat pipeline, over the WebSocket that mod_audio_stream
|
||||
opens outbound to Astra.
|
||||
|
||||
mod_audio_stream has no dialplan application, only the ESL API command
|
||||
`uuid_audio_stream` (confirmed against the module's own source and by
|
||||
testing). Confirmed by testing (2026-09-12): issuing `uuid_audio_stream` from
|
||||
this same process - even over a second, separate ESL connection - reliably
|
||||
fails the WebSocket connect for real external/gateway calls (instant
|
||||
"connection error", no such issue for the loopback test line). Matches a
|
||||
known mod_audio_stream issue (amigniter/mod_audio_stream#92): "It's an issue
|
||||
with using uuid_audio_stream on the same call session uuid as the Python
|
||||
script that's listening for the WebSocket, separating these processes
|
||||
resolves the problem." So the command is shelled out to `fs_cli` as a
|
||||
genuinely separate OS process, not issued from within this one at all.
|
||||
|
||||
Second gap found by testing (2026-09-13): mod_audio_stream does NOT play
|
||||
audio back on its own. Astra's `streamAudio` message only makes it write a
|
||||
temp WAV file and fire a `mod_audio_stream::play` CUSTOM event containing that
|
||||
path (audio_streamer_glue.cpp:290-303) - something has to catch that event and
|
||||
actually play the file. Nothing in the module or the dialplan does this, so
|
||||
this process stays on the call (instead of returning right after park()) and
|
||||
plays each file back as the events arrive, until the call hangs up - with the
|
||||
`playback` application over this same outbound socket. Unlike uuid_audio_stream
|
||||
that is not an "api" command but an execute-style application call, exactly
|
||||
like the answer()/park() that already work here, so it does not need the
|
||||
separate-process detour.
|
||||
|
||||
Getting those CUSTOM events delivered at all took two subscriptions plus one
|
||||
workaround; see run() and event_body_json() for the details.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import gevent
|
||||
import greenswitch
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("pipecat_bootstrap")
|
||||
|
||||
BIND_ADDRESS = os.environ.get("PIPECAT_BOOTSTRAP_BIND", "127.0.0.1")
|
||||
BIND_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095"))
|
||||
ASTRA_WS_URL = os.environ.get("ASTRA_TELEFON_WS_URL", "")
|
||||
STREAM_MIX_TYPE = "mono"
|
||||
STREAM_SAMPLE_RATE = "16k" # matches NemotronSTTService's hard 16 kHz requirement on the Astra side
|
||||
# Upper bound so a greenlet can never outlive a call FreeSWITCH itself forgot
|
||||
# to tell us ended - failure mode is "one stray idle greenlet", not a runaway.
|
||||
MAX_CALL_SECONDS = 30 * 60
|
||||
|
||||
FS_HOST = "127.0.0.1"
|
||||
FS_PORT = os.environ.get("FS_PORT", "8022")
|
||||
FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon")
|
||||
FS_CLI = os.environ.get("FS_CLI_BIN", "/opt/homebrew/bin/fs_cli")
|
||||
|
||||
|
||||
def fs_cli(command: str) -> str:
|
||||
"""Run one `api` command via `fs_cli` as its own OS process - see module
|
||||
docstring for why commands for this call must not go over this process's
|
||||
own outbound-socket connection."""
|
||||
result = subprocess.run(
|
||||
[FS_CLI, "-H", FS_HOST, "-P", FS_PORT, "-p", FS_ESL_PASSWORD, "-x", command],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return (result.stdout or result.stderr).strip()
|
||||
|
||||
|
||||
def event_body_json(event) -> dict | None:
|
||||
"""Read a FreeSWITCH event's body (the part after the blank line) as JSON.
|
||||
|
||||
greenswitch's ESLEvent.parse_data() only knows "key: value" lines, so an
|
||||
event body - mod_audio_stream sends bare JSON, which has no ": " in it -
|
||||
is never split off as its own field. It ends up appended to the value of
|
||||
whatever header came last, which in plain format is always the body's own
|
||||
"Content-Length". So `headers["Content-Length"]` reads e.g.
|
||||
'84\\n\\n{"audioDataType":"wav","file":"/tmp/....wav"}' - the payload is
|
||||
there, just glued to a number. Cut at the first "{" and parse that.
|
||||
"""
|
||||
raw = event.headers.get("Content-Length") or ""
|
||||
start = raw.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(raw[start:])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
class PipecatBridge:
|
||||
"""One instance per call, per greenswitch's OutboundESLServer contract."""
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
self.call_id = getattr(session, "uuid", "?")
|
||||
self.hung_up = gevent.event.Event()
|
||||
|
||||
def on_play_event(self, event):
|
||||
payload = event_body_json(event)
|
||||
if payload is None:
|
||||
logger.warning(
|
||||
"Anruf %s: mod_audio_stream::play-Event ohne lesbaren JSON-Rumpf: %r",
|
||||
self.call_id, event.headers,
|
||||
)
|
||||
return
|
||||
path = payload.get("file")
|
||||
if not path:
|
||||
logger.warning("Anruf %s: play-Event ohne Dateipfad: %r", self.call_id, payload)
|
||||
return
|
||||
# Same mechanism Logpy-AgentOne already uses successfully in
|
||||
# production (dialplan `playback` app) - over this session's own
|
||||
# connection, unlike uuid_audio_stream this is an "execute"-style
|
||||
# application call (like answer()/park(), already proven to work
|
||||
# here), not an "api" command.
|
||||
self.session.playback(path, block=False)
|
||||
logger.info("Anruf %s: spiele %s ab", self.call_id, path)
|
||||
|
||||
def on_hangup(self, event):
|
||||
self.hung_up.set()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# Two separate subscriptions, both required, in this order:
|
||||
#
|
||||
# `myevents` turns on this session's own event feed, but it does
|
||||
# NOT mean "all events" - mod_event_socket hardcodes a whitelist
|
||||
# of ~23 event ids there (mod_event_socket.c, parse_command(),
|
||||
# "myevents" branch), all of them CHANNEL_*/DTMF/TALK. CUSTOM is
|
||||
# not in it, so no module's CUSTOM event can ever arrive on a
|
||||
# myevents-only socket, no matter its subclass.
|
||||
#
|
||||
# `event plain CUSTOM <subclass>` adds exactly that: it sets
|
||||
# event_list[CUSTOM] plus a subclass entry in the listener's
|
||||
# event_hash. It is purely additive - it does not reset the
|
||||
# whitelist myevents just installed (only `noevents`/`nixevent`
|
||||
# clear entries), so the CHANNEL_* events keep coming. The
|
||||
# LFLAG_MYEVENTS flag stays set too, so the CUSTOM events we now
|
||||
# get are still filtered down to this channel's own Unique-ID.
|
||||
#
|
||||
# Needs the socket to be "full" (dialplan/originate say
|
||||
# `socket(host:port async full)`): mod_event_socket drops every
|
||||
# command past this point for a non-full outbound listener.
|
||||
self.session.myevents()
|
||||
self.session.send("event plain CUSTOM mod_audio_stream::play")
|
||||
self.session.linger()
|
||||
self.session.register_handle("mod_audio_stream::play", self.on_play_event)
|
||||
self.session.register_handle("CHANNEL_HANGUP", self.on_hangup)
|
||||
self.session.answer()
|
||||
antwort = fs_cli(
|
||||
f"uuid_audio_stream {self.call_id} start {ASTRA_WS_URL} "
|
||||
f"{STREAM_MIX_TYPE} {STREAM_SAMPLE_RATE} {self.call_id}"
|
||||
)
|
||||
logger.info("Anruf %s: uuid_audio_stream gestartet, Antwort: %r", self.call_id, antwort)
|
||||
self.session.park()
|
||||
# Stay on the line - on_play_event needs to keep firing for the
|
||||
# whole call, not just the first bot utterance.
|
||||
self.hung_up.wait(timeout=MAX_CALL_SECONDS)
|
||||
except Exception:
|
||||
logger.exception("Anruf %s: Bootstrap fehlgeschlagen", self.call_id)
|
||||
finally:
|
||||
self.session.stop()
|
||||
|
||||
|
||||
def main():
|
||||
if not ASTRA_WS_URL:
|
||||
sys.exit("ASTRA_TELEFON_WS_URL fehlt in der Umgebung (.env) - Abbruch.")
|
||||
logger.info("Pipecat-Bootstrap hoert auf %s:%s, Ziel: %s", BIND_ADDRESS, BIND_PORT, ASTRA_WS_URL)
|
||||
server = greenswitch.OutboundESLServer(
|
||||
bind_address=BIND_ADDRESS, bind_port=BIND_PORT,
|
||||
application=PipecatBridge, max_connections=5,
|
||||
)
|
||||
server.listen()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -6,3 +6,7 @@ piper-tts>=1.2
|
|||
certifi
|
||||
# Nur nötig für STT_BACKEND=faster; der Standard ist whisper.cpp.
|
||||
faster-whisper>=1.2
|
||||
|
||||
# Outbound-ESL-Bootstrap fuer die Pipecat-Testleitung (dialog/pipecat_bootstrap.py):
|
||||
# nimmt FreeSWITCHs socket()-Uebergabe an, macht answer()+uuid_audio_stream+park().
|
||||
greenswitch>=0.0.19
|
||||
|
|
|
|||
|
|
@ -129,6 +129,16 @@ if [ "${status:-}" = "UP" ]; then
|
|||
nohup python3 -u -m pipe.monitor >> telefon/monitor.log 2>&1 &
|
||||
echo "Stoerungswache gestartet (Log: telefon/monitor.log)"
|
||||
fi
|
||||
# Pipecat-Testleitung (Nebenstelle 7501) - nur wenn konfiguriert, siehe
|
||||
# dialog/pipecat_bootstrap.py und dialplan/default/06_agentzwei_pipecat_test.xml.
|
||||
if [ -n "${ASTRA_TELEFON_WS_URL:-}" ]; then
|
||||
if pgrep -f "[d]ialog.pipecat_bootstrap" >/dev/null; then
|
||||
echo "Pipecat-Bootstrap laeuft bereits."
|
||||
else
|
||||
nohup python3 -u -m dialog.pipecat_bootstrap >> telefon/pipecat_bootstrap.log 2>&1 &
|
||||
echo "Pipecat-Bootstrap gestartet (Log: telefon/pipecat_bootstrap.log)"
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
echo
|
||||
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue