Files

317 lines
13 KiB
Python

#!/usr/bin/env python3
"""Mock AI Agent — FreeSWITCH/ESL 版.
方案: FreeSWITCH 无 ARI externalMedia 式外挂媒体 API, 标准做法是本 agent 自身充当
SIP UAS 端点 (等价于 asterisk 版的 externalMedia 腿), 控制面走 ESL:
0. ESL TCP 连接 fs:8021 (auth)
1. 本侧 SIP UAS 监听 (5062) + RTP 监听 (40001)
2. ESL originate 腿1: sofia/external/agent@<本容器>:5062 &park() ← 本侧 UAS 应答
3. ESL originate 腿2: sofia/gateway/mock-trunk/<被叫> &park() ← mock-provider 应答
4. ESL uuid_bridge 腿1 腿2 → 桥接, RTP 经 FreeSWITCH 双向
5. RTP: RX=被叫音频(模拟 ASR 输入, RMS 统计); TX=440Hz 间歇音(模拟 LLM/TTS 输出)
6. ESL uuid_kill 双腿 → BYE, 输出双向统计
纯 stdlib (asyncio + socket)。换真实 LLM/ASR: 替换 TX 音源与 RX 消费即可。
用法: mock-agent.py --esl-host fs1 --sip-host <本容器名>:5062 \
--number +15105550123 [--duration 15]
"""
import argparse, asyncio, math, os, random, socket, struct, time
def log(*a): print(f"[agent {time.strftime('%H:%M:%S')}]", *a, flush=True)
# ---------- G.711 编码 ----------
def ulaw_encode(s: int) -> int:
s = max(-32635, min(32635, s)); sign = 0x80 if s < 0 else 0
if sign: s = -s
s += 0x84
e = 7
for i in range(7, -1, -1):
if s & (0x40 << i): e = i; break
return (~(sign | (e << 4) | ((s >> (e + 3)) & 0x0F))) & 0xFF
def alaw_encode(s: int) -> int:
s = max(-32635, min(32635, s)); sign = 0x80 if s < 0 else 0
if sign: s = -s
e = 7
for i in range(7, -1, -1):
if s & (0x40 << i): e = i; break
m = (s >> (e + 3)) & 0x0F
return ((sign | (e << 4) | m) ^ 0x55) & 0xFF
def ulaw_decode(u: int) -> int:
u = ~u & 0xFF
sign = u & 0x80; e = (u >> 4) & 0x07; m = u & 0x0F
s = ((m << (e + 3)) + 0x84) << 2
return -s if sign else s
def alaw_decode(a: int) -> int:
a ^= 0x55
t = (a & 0x0F) << 4
seg = (a & 0x70) >> 4
if seg == 0: t += 8
else:
t += 0x108
if seg > 1: t <<= (seg - 1)
return -t if a & 0x80 else t
def tone_payload(codec: str, ts: float) -> bytes:
"""20ms / 160 samples @8kHz, 440Hz 500ms-on 500ms-off (模拟 TTS)"""
enc = ulaw_encode if codec == "PCMU" else alaw_encode
out = bytearray()
on = (ts % 1.0) < 0.5
for i in range(160):
t = ts + i / 8000.0
v = int(6000 * math.sin(2 * math.pi * 440 * t)) if on else 0
out.append(enc(v))
return bytes(out)
# ---------- SIP 报文 (与 mock-provider 同源) ----------
def parse_msg(data: bytes):
head, _, body = data.decode("utf-8", "replace").partition("\r\n\r\n")
lines = head.split("\r\n")
headers = []
for ln in lines[1:]:
n, _, v = ln.partition(":")
headers.append((n.strip().lower(), v.strip()))
return lines[0], headers, body
def hget(headers, name):
for n, v in headers:
if n == name: return v
return None
def build_resp(first, headers, code, reason, tag=None, extra=None, body=""):
out = [f"SIP/2.0 {code} {reason}"]
for n, v in headers:
if n == "via": out.append(f"Via: {v}")
out.append(f"From: {hget(headers,'from')}")
to = hget(headers, "to")
if tag and "tag=" not in to: to = f"{to};tag={tag}"
out.append(f"To: {to}")
out.append(f"Call-ID: {hget(headers,'call-id')}")
out.append(f"CSeq: {hget(headers,'cseq')}")
if extra:
out += extra
if body:
out.append("Content-Type: application/sdp")
out.append(f"Content-Length: {len(body)}")
return ("\r\n".join(out) + "\r\n\r\n" + body).encode()
def parse_sdp_offer(body: str):
ip, port, pts = None, None, []
for ln in body.splitlines():
if ln.startswith("c=IN IP4 "): ip = ln.split()[2]
elif ln.startswith("m=audio "):
parts = ln.split(); port = int(parts[1]); pts = [p for p in parts[3:] if p.isdigit()]
return ip, port, pts
def pick_codec(pts):
if "0" in pts: return "0", "PCMU"
if "8" in pts: return "8", "PCMA"
return (pts[0], "PCMU") if pts else ("0", "PCMU")
# ---------- SIP UAS (被 FreeSWITCH originate 的"externalMedia 腿") ----------
class SipUas(asyncio.DatagramProtocol):
"""应答 FS 的 INVITE (200+SDP), 处理 ACK/BYE/OPTIONS/re-INVITE/重传."""
def __init__(self, my_ip, sip_port, rtp_port):
self.my_ip, self.sip_port, self.rtp_port = my_ip, sip_port, rtp_port
self.transport = None
self.tag = os.urandom(5).hex()
self.cid = None
self.fs_rtp = None # FS 侧 RTP 地址 (INVITE SDP)
self.pt, self.codec = "0", "PCMU"
self.confirmed = False
self.ended = asyncio.Event()
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
first, headers, body = parse_msg(data)
method = first.split()[0] if first else "?"
if method == "INVITE":
cid = hget(headers, "call-id")
new_call = self.cid is None
if new_call:
self.cid = cid
log(f"INVITE from FS {addr[0]}:{addr[1]} call_id={cid}")
ip, port, pts = parse_sdp_offer(body)
if ip and port:
self.fs_rtp = (ip, port) # re-INVITE 时同步更新
if new_call:
self.pt, self.codec = pick_codec(pts)
# 重传与 re-INVITE 统一: 按当前请求头重建 200 OK (CSeq 正确)
sdp = (f"v=0\r\no=agent {random.randint(1,10**9)} {random.randint(1,10**9)} IN IP4 {self.my_ip}\r\n"
f"s=agent\r\nc=IN IP4 {self.my_ip}\r\nt=0 0\r\n"
f"m=audio {self.rtp_port} RTP/AVP {self.pt}\r\n"
f"a=rtpmap:{self.pt} {self.codec}/8000\r\na=sendrecv\r\n")
self.transport.sendto(build_resp(
first, headers, 200, "OK", tag=self.tag,
extra=[f"Contact: <sip:agent@{self.my_ip}:{self.sip_port}>",
"User-Agent: MockAgent/1.0"], body=sdp), addr)
elif method == "ACK":
if not self.confirmed:
self.confirmed = True
log(f"leg answered (ACK), fs rtp={self.fs_rtp} codec={self.codec}")
elif method == "BYE":
self.transport.sendto(build_resp(first, headers, 200, "OK", tag=self.tag), addr)
self.ended.set()
elif method == "OPTIONS":
self.transport.sendto(build_resp(first, headers, 200, "OK", tag=self.tag), addr)
# ---------- RTP 媒体 ----------
class Rx(asyncio.DatagramProtocol):
def __init__(self, stats):
self.stats = stats
def datagram_received(self, data, addr):
if len(data) < 12: return
pt = data[1] & 0x7F
self.stats["rx"] += 1
payload = data[12:]
dec = [(ulaw_decode(b) if pt == 0 else alaw_decode(b)) for b in payload[:40]]
self.stats["amp_sum"] += sum(abs(x) for x in dec) / max(1, len(dec))
self.stats["tx_to"] = addr # 始终跟随最新来源 (自适应媒体路径)
if self.stats["rx"] % 250 == 0:
log(f"RX frames={self.stats['rx']} avg_rms={self.stats['amp_sum']/self.stats['rx']:.0f}")
# ---------- ESL 客户端 (极简: auth + api, 同步命令式) ----------
class Esl:
def __init__(self, host, port, password):
self.host, self.port, self.password = host, port, password
async def connect(self):
self.r, self.w = await asyncio.open_connection(self.host, self.port)
m = await self._read()
if m.get("content-type") != "auth/request":
raise ConnectionError(f"unexpected esl greeting: {m}")
self.w.write(f"auth {self.password}\n\n".encode())
await self.w.drain()
m = await self._read()
if "+OK" not in m.get("reply-text", ""):
raise ConnectionError(f"esl auth failed: {m}")
async def _read(self):
headers = {}
while True:
line = await self.r.readline()
if not line: raise ConnectionError("esl closed")
line = line.decode("utf-8", "replace").rstrip("\r\n")
if not line: break
k, _, v = line.partition(":")
headers[k.strip().lower()] = v.strip()
n = int(headers.get("content-length", 0) or 0)
headers["_body"] = (await self.r.readexactly(n)).decode("utf-8", "replace") if n else ""
return headers
async def api(self, cmd):
self.w.write(f"api {cmd}\n\n".encode())
await self.w.drain()
return (await self._read())["_body"].strip()
async def close(self):
try: self.w.close()
except Exception: pass
# ---------- 主流程 ----------
async def run(args):
my_ip = socket.gethostbyname(socket.gethostname())
sip_port = int(args.sip_host.rsplit(":", 1)[1])
suffix = f"{int(time.time())}-{random.randint(100, 999)}"
agent_uuid, callee_uuid = f"agent-{suffix}", f"callee-{suffix}"
tmo = int(args.ring_timeout)
stats = {"rx": 0, "amp_sum": 0.0, "tx": 0, "tx_to": None, "codec": "PCMU"}
loop = asyncio.get_running_loop()
uas_t, uas = await loop.create_datagram_endpoint(
lambda: SipUas(my_ip, sip_port, args.rtp_port), local_addr=("0.0.0.0", sip_port))
rtp_t, _ = await loop.create_datagram_endpoint(
lambda: Rx(stats), local_addr=("0.0.0.0", args.rtp_port))
log(f"agent up: sip=udp/{sip_port} rtp=udp/{args.rtp_port} ip={my_ip}")
esl = Esl(args.esl_host, args.esl_port, args.esl_password)
await esl.connect()
log(f"esl connected: {args.esl_host}:{args.esl_port}")
def fail(msg, detail=None):
log(f"FAIL: {msg}", detail or "")
raise SystemExit(1)
try:
# 腿1: 本 agent (SIP UAS, 等价 externalMedia): 应答后 park
r = await esl.api(
f"originate {{origination_uuid={agent_uuid},"
f"origination_caller_id_number={args.caller_id},originate_timeout={tmo}}}"
f"sofia/external/agent@{args.sip_host} &park()")
if not r.startswith("+OK"): fail("originate agent leg", r)
log(f"leg1 (agent UAS) up: {r}")
# 腿2: 被叫经 mock-trunk 网关 (网关冷启动自动重试)
for attempt in range(3):
r = await esl.api(
f"originate {{origination_uuid={callee_uuid},"
f"origination_caller_id_number={args.caller_id},originate_timeout={tmo}}}"
f"sofia/gateway/mock-trunk/{args.number} &park()")
if r.startswith("+OK"): break
log(f"leg2 originate retry {attempt + 1}: {r}")
await asyncio.sleep(2)
if not r.startswith("+OK"): fail("originate callee leg", r)
log(f"leg2 (callee) answered: {r}")
stats["codec"] = uas.codec
if uas.fs_rtp and not stats["tx_to"]:
stats["tx_to"] = uas.fs_rtp
log(f"fs rtp peer (INVITE SDP): {uas.fs_rtp[0]}:{uas.fs_rtp[1]}")
# 桥接双腿 → RTP 经 FS 双向
r = await esl.api(f"uuid_bridge {agent_uuid} {callee_uuid}")
if not r.startswith("+OK"): fail("uuid_bridge", r)
log(f"bridged [{agent_uuid} + {callee_uuid}], streaming {args.duration}s "
f"(RX=ASR mock, TX=440Hz LLM/TTS mock)")
# 媒体收发
async def tx():
seq = random.randint(0, 65535); ts = random.randint(0, 10**6)
ssrc = random.randint(1, 2**31)
pt = 0 if stats["codec"] == "PCMU" else 8
t0 = time.time()
while time.time() - t0 < args.duration:
if stats["tx_to"]:
hdr = struct.pack("!BBHII", 0x80, pt, seq & 0xFFFF,
ts & 0xFFFFFFFF, ssrc)
rtp_t.sendto(hdr + tone_payload(stats["codec"], time.time() - t0),
stats["tx_to"])
stats["tx"] += 1
seq += 1; ts += 160
await asyncio.sleep(0.02)
await asyncio.wait_for(tx(), timeout=args.duration + 5)
finally:
for u in (callee_uuid, agent_uuid):
try: await esl.api(f"uuid_kill {u}")
except Exception: pass
await esl.close()
uas_t.close(); rtp_t.close()
await asyncio.sleep(0.3) # 等 BYE 应答落地
n = stats["rx"]
amp = stats["amp_sum"] / n if n else 0.0
print(f"RESULT number={args.number} rx_frames={n} rx_avg_rms={amp:.0f} "
f"tx_frames={stats['tx']} bidirectional={'YES' if n > 50 and amp > 100 else 'NO'}",
flush=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--esl-host", default="fs1")
ap.add_argument("--esl-port", type=int, default=8021)
ap.add_argument("--esl-password", default="esl_mock_a95a037399153e9092b653fc")
ap.add_argument("--sip-host", required=True, help="本侧 SIP 地址 host:port (供 FS INVITE)")
ap.add_argument("--rtp-port", type=int, default=40001)
ap.add_argument("--number", default="+15105550123")
ap.add_argument("--caller-id", default="+15109990001")
ap.add_argument("--duration", type=float, default=15)
ap.add_argument("--ring-timeout", type=float, default=30)
args = ap.parse_args()
asyncio.run(run(args))
if __name__ == "__main__":
main()