Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/extraScripts/HesabixTelephonyConnector/app/main.py
2026-08-09 02:35:57 +03:30

694 lines
22 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Hesabix Telephony Connector — عامل سمت Issabel/Asterisk
رویدادهای AMI را به Hesabix می‌فرستد، دستورات Originate/Softphone را اجرا می‌کند،
و تونل رسانه خروجی (Softphone Relay) را نگه می‌دارد.
نصب: روی سرور تلفن مشتری (نه روی سرور حسابیکس).
پیکربندی از متغیر محیطی یا فایل .env:
HESABIX_API_URL=https://api.example.com
HESABIX_BUSINESS_ID=12
HESABIX_PBX_ID=3
HESABIX_CONNECTOR_TOKEN=hsx_tel_...
AMI_HOST=127.0.0.1
AMI_PORT=5038
AMI_USER=hesabix
AMI_SECRET=secret
AUDIOSOCKET_HOST=127.0.0.1
AUDIOSOCKET_PORT=9092
MEDIA_TUNNEL_ENABLED=1
"""
from __future__ import annotations
import json
import logging
import os
import socket
import threading
import time
import uuid
from typing import Any, Dict, Optional
from urllib import error, request
LOG = logging.getLogger("hesabix.telephony.connector")
try:
from media_audiosocket import AudioSocketServer
from media_tunnel import MediaTunnelClient
except ImportError:
# وقتی به‌صورت پکیج از parent اجرا شود
from app.media_audiosocket import AudioSocketServer # type: ignore
from app.media_tunnel import MediaTunnelClient # type: ignore
def load_dotenv(path: str) -> None:
"""بارگذاری ساده .env بدون وابستگی خارجی (برای وقتی EnvironmentFile اعمال نشده)."""
if not os.path.isfile(path):
return
try:
with open(path, "r", encoding="utf-8") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip("'").strip('"')
if key and key not in os.environ:
os.environ[key] = val
except Exception as e:
LOG.warning("could not read .env (%s): %s", path, e)
def env(key: str, default: Optional[str] = None) -> str:
v = os.environ.get(key, default)
if v is None or v == "":
raise RuntimeError(f"Missing env: {key}")
return v
class HesabixClient:
def __init__(self, base_url: str, business_id: int, pbx_id: int, token: str) -> None:
self.base = base_url.rstrip("/")
self.business_id = business_id
self.pbx_id = pbx_id
self.token = token
def _headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"X-Hesabix-Business-Id": str(self.business_id),
"X-Hesabix-Pbx-Id": str(self.pbx_id),
}
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
req = request.Request(
f"{self.base}{path}",
data=json.dumps(payload).encode("utf-8"),
headers=self._headers(),
method="POST",
)
with request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
def _get(self, path: str) -> Dict[str, Any]:
req = request.Request(f"{self.base}{path}", headers=self._headers(), method="GET")
with request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
def heartbeat(self, status: str = "ok", **extra: Any) -> None:
body = {"status": status, "connector_version": "0.1.0", **extra}
try:
self._post("/api/v1/telephony/connector/heartbeat", body)
except Exception as e:
LOG.warning("heartbeat failed: %s", e)
def send_event(self, event: Dict[str, Any]) -> Dict[str, Any]:
try:
data = self._post("/api/v1/telephony/connector/events", event)
payload = data.get("data") if isinstance(data, dict) else None
return payload if isinstance(payload, dict) else (data if isinstance(data, dict) else {})
except Exception as e:
LOG.warning("event failed: %s", e)
return {}
def poll_commands(self) -> list:
try:
data = self._get("/api/v1/telephony/connector/commands/poll?limit=10")
payload = data.get("data") or data
items = payload.get("items") if isinstance(payload, dict) else []
return items or []
except Exception as e:
LOG.warning("poll failed: %s", e)
return []
def ack(self, command_id: str, accepted: bool = True, **extra: Any) -> None:
try:
self._post(
f"/api/v1/telephony/connector/commands/{command_id}/ack",
{"accepted": accepted, **extra},
)
except Exception as e:
LOG.warning("ack failed: %s", e)
class AmiClient:
"""کلاینت AMI بسیار ساده برای Login / Originate / رویدادهای متنی."""
def __init__(self, host: str, port: int, username: str, secret: str) -> None:
self.host = host
self.port = port
self.username = username
self.secret = secret
self._sock: Optional[socket.socket] = None
self._buf = b""
self._lock = threading.Lock()
def connect(self) -> None:
self._sock = socket.create_connection((self.host, self.port), timeout=10)
self._sock.settimeout(1.0)
self._login()
def close(self) -> None:
if self._sock:
try:
self._sock.close()
except Exception:
pass
self._sock = None
def _send(self, lines: Dict[str, str]) -> None:
assert self._sock is not None
payload = "".join(f"{k}: {v}\r\n" for k, v in lines.items()) + "\r\n"
with self._lock:
self._sock.sendall(payload.encode("utf-8"))
def _login(self) -> None:
self._send({"Action": "Login", "Username": self.username, "Secret": self.secret})
# پاسخ Login را بخوان
deadline = time.time() + 5
buf = b""
assert self._sock is not None
while time.time() < deadline:
try:
chunk = self._sock.recv(4096)
if not chunk:
break
buf += chunk
if b"\r\n\r\n" in buf:
break
except socket.timeout:
continue
text = buf.decode("utf-8", errors="ignore")
if "Success" not in text and "Authentication accepted" not in text:
raise RuntimeError(f"AMI login rejected: {text[:300]!r}")
def originate(self, extension: str, destination: str, context: str = "from-internal", timeout_ms: int = 30000) -> None:
self._send(
{
"Action": "Originate",
"Channel": f"Local/{extension}@{context}",
"Context": context,
"Exten": destination,
"Priority": "1",
"Timeout": str(timeout_ms),
"CallerID": extension,
"Async": "true",
}
)
def originate_to_application(
self,
channel: str,
application: str,
data: str,
*,
caller_id: str = "",
timeout_ms: int = 30000,
variable: Optional[str] = None,
) -> None:
payload: Dict[str, str] = {
"Action": "Originate",
"Channel": channel,
"Application": application,
"Data": data,
"Timeout": str(timeout_ms),
"Async": "true",
}
if caller_id:
payload["CallerID"] = caller_id
if variable:
payload["Variable"] = variable
self._send(payload)
def play_dtmf(self, channel: str, digit: str) -> None:
self._send({"Action": "PlayDTMF", "Channel": channel, "Digit": digit})
def hangup(self, channel: str) -> None:
self._send({"Action": "Hangup", "Channel": channel})
def set_var(self, channel: str, variable: str, value: str) -> None:
self._send(
{
"Action": "Setvar",
"Channel": channel,
"Variable": variable,
"Value": value,
}
)
def redirect(self, channel: str, exten: str, context: str = "from-internal") -> None:
self._send(
{
"Action": "Redirect",
"Channel": channel,
"Exten": exten,
"Context": context,
"Priority": "1",
}
)
def park_or_hold(self, channel: str) -> None:
# Issabel/Asterisk: soft hold via Park or Redirect to parking lot if configured
self._send(
{
"Action": "Park",
"Channel": channel,
}
)
def read_events(self):
assert self._sock is not None
while True:
try:
chunk = self._sock.recv(4096)
if not chunk:
break
self._buf += chunk
while b"\r\n\r\n" in self._buf:
raw, self._buf = self._buf.split(b"\r\n\r\n", 1)
text = raw.decode("utf-8", errors="ignore")
ev: Dict[str, str] = {}
for line in text.split("\r\n"):
if ": " in line:
k, v = line.split(": ", 1)
ev[k.strip()] = v.strip()
if ev:
yield ev
except socket.timeout:
yield {"Event": "Idle"}
except Exception as e:
LOG.error("AMI read error: %s", e)
break
def map_ami_event(ev: Dict[str, str]) -> Optional[Dict[str, Any]]:
name = ev.get("Event")
if name == "UserEvent" and (ev.get("UserEvent") or "") == "HesabixInboundSoftphone":
extension = ev.get("Extension") or "500"
return {
"event_id": str(uuid.uuid4()),
"type": "call.ringing",
"uniqueid": ev.get("Uniqueid") or str(uuid.uuid4()),
"linkedid": ev.get("Linkedid"),
"direction": "inbound",
"from": ev.get("CallerID") or ev.get("CallerIDNum"),
"to": extension,
"extension": extension,
"channel": ev.get("Channel"),
"softphone_relay": True,
}
if name == "DialBegin":
return {
"event_id": str(uuid.uuid4()),
"type": "call.ringing",
"uniqueid": ev.get("DestUniqueid") or ev.get("Uniqueid") or str(uuid.uuid4()),
"linkedid": ev.get("Linkedid"),
"direction": "inbound",
"from": ev.get("CallerIDNum"),
"to": ev.get("DestExten") or ev.get("Exten"),
"extension": ev.get("DestCallerIDNum") or ev.get("DestExten"),
"channel": ev.get("Channel"),
}
if name == "Hangup":
return {
"event_id": str(uuid.uuid4()),
"type": "call.ended",
"uniqueid": ev.get("Uniqueid") or str(uuid.uuid4()),
"linkedid": ev.get("Linkedid"),
"hangup_cause": ev.get("Cause"),
"extension": ev.get("CallerIDNum"),
"from": ev.get("CallerIDNum"),
"to": ev.get("ConnectedLineNum"),
}
return None
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# مسیرهای محتمل .env
for candidate in (
os.environ.get("HESABIX_PBX_ENV"),
"/opt/HesabixTelephonyConnector/.env",
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"),
os.path.join(os.getcwd(), ".env"),
):
if candidate:
load_dotenv(candidate)
try:
api = HesabixClient(
base_url=env("HESABIX_API_URL"),
business_id=int(env("HESABIX_BUSINESS_ID")),
pbx_id=int(env("HESABIX_PBX_ID")),
token=env("HESABIX_CONNECTOR_TOKEN"),
)
ami = AmiClient(
host=os.environ.get("AMI_HOST", "127.0.0.1"),
port=int(os.environ.get("AMI_PORT", "5038")),
username=env("AMI_USER"),
secret=env("AMI_SECRET"),
)
except Exception as e:
LOG.error("پیکربندی ناقص یا نامعتبر: %s", e)
return 1
LOG.info("Connecting AMI %s@%s:%s …", ami.username, ami.host, ami.port)
try:
ami.connect()
except Exception as e:
LOG.error("اتصال/ورود AMI ناموفق: %s", e)
LOG.error("روی سرور تلفن بررسی کنید: manager.conf، AMI_USER/AMI_SECRET، و: asterisk -rx 'manager show connected'")
return 1
try:
api.heartbeat(status="ok", media_tunnel=False)
except Exception as e:
LOG.error("heartbeat حسابیکس ناموفق: %s", e)
return 1
# Softphone media state
as_uuid_to_session: Dict[str, str] = {}
session_to_as_uuid: Dict[str, str] = {}
session_to_bridge_id: Dict[str, str] = {}
session_to_call_id: Dict[str, Any] = {}
online_agents_by_ext: Dict[str, str] = {} # extension -> session_id
media_lock = threading.Lock()
tunnel_ref: Dict[str, Any] = {"client": None}
def _notify_bridge_active(session_id: str, as_uuid: str, *, call_id: Any = None) -> None:
client = tunnel_ref.get("client")
if not client or not session_id:
return
with media_lock:
bridge_id = session_to_bridge_id.get(session_id)
if call_id is None:
call_id = session_to_call_id.get(session_id)
payload: Dict[str, Any] = {
"type": "bridge.active",
"session_id": session_id,
"audiosocket_uuid": as_uuid,
}
if bridge_id:
payload["bridge_id"] = bridge_id
if call_id is not None:
payload["call_id"] = call_id
client.send_json(payload)
def on_as_pcm(as_uuid: str, pcm: bytes) -> None:
with media_lock:
session_id = as_uuid_to_session.get(as_uuid)
client: Optional[MediaTunnelClient] = tunnel_ref.get("client")
if session_id and client:
client.send_pcm(session_id, pcm)
def on_as_connect(as_uuid: str) -> None:
with media_lock:
session_id = as_uuid_to_session.get(as_uuid)
if session_id:
LOG.info("AudioSocket ready → bridge.active session=%s uuid=%s", session_id, as_uuid)
_notify_bridge_active(session_id, as_uuid)
def on_as_hangup(as_uuid: str) -> None:
with media_lock:
session_id = as_uuid_to_session.pop(as_uuid, None)
if session_id:
session_to_as_uuid.pop(session_id, None)
session_to_bridge_id.pop(session_id, None)
client = tunnel_ref.get("client")
if client and session_id:
client.send_json({"type": "bridge.failed", "session_id": session_id, "reason": "audiosocket_hangup", "audiosocket_uuid": as_uuid})
def on_tunnel_pcm(session_id: str, pcm: bytes) -> None:
with media_lock:
as_uuid = session_to_as_uuid.get(session_id)
if as_uuid:
audio_server.send_pcm(as_uuid, pcm)
def on_tunnel_json(payload: Dict[str, Any]) -> None:
typ = payload.get("type")
if typ == "bridge.start":
session_id = str(payload.get("session_id") or "")
as_uuid = str(payload.get("audiosocket_uuid") or "")
bridge_id = str(payload.get("bridge_id") or "")
if session_id and as_uuid:
with media_lock:
as_uuid_to_session[as_uuid] = session_id
session_to_as_uuid[session_id] = as_uuid
if bridge_id:
session_to_bridge_id[session_id] = bridge_id
# اگر AudioSocket از قبل وصل است (نادر)، فوری active کن؛ وگرنه بعد از TCP connect
if audio_server.has_connection(as_uuid):
_notify_bridge_active(session_id, as_uuid, call_id=payload.get("call_id"))
else:
LOG.info("bridge.start mapped session=%s uuid=%s (waiting AudioSocket)", session_id, as_uuid)
elif typ == "bridge.stop":
session_id = str(payload.get("session_id") or "")
as_uuid = str(payload.get("audiosocket_uuid") or "")
with media_lock:
if not as_uuid:
as_uuid = session_to_as_uuid.pop(session_id, "")
else:
session_to_as_uuid.pop(session_id, None)
if as_uuid:
as_uuid_to_session.pop(as_uuid, None)
if session_id:
session_to_bridge_id.pop(session_id, None)
session_to_call_id.pop(session_id, None)
if as_uuid:
audio_server.hangup(as_uuid)
elif typ == "agent.online":
ext = str(payload.get("extension") or "").strip()
sid = str(payload.get("session_id") or "").strip()
if ext and sid:
with media_lock:
online_agents_by_ext[ext] = sid
LOG.info("softphone agent online ext=%s session=%s", ext, sid)
elif typ == "agent.offline":
sid = str(payload.get("session_id") or "").strip()
ext = str(payload.get("extension") or "").strip()
with media_lock:
if ext and online_agents_by_ext.get(ext) == sid:
online_agents_by_ext.pop(ext, None)
elif sid:
for k, v in list(online_agents_by_ext.items()):
if v == sid:
online_agents_by_ext.pop(k, None)
LOG.info("softphone agent offline session=%s", sid)
audio_host = os.environ.get("AUDIOSOCKET_HOST", "127.0.0.1")
audio_port = int(os.environ.get("AUDIOSOCKET_PORT", "9092"))
audio_server = AudioSocketServer(
host=audio_host,
port=audio_port,
on_pcm=on_as_pcm,
on_hangup=on_as_hangup,
on_connect=on_as_connect,
)
try:
audio_server.start()
except Exception as e:
LOG.error("شروع AudioSocket ناموفق: %s — Softphone Relay کار نمی‌کند تا پورت آزاد شود.", e)
media_enabled = os.environ.get("MEDIA_TUNNEL_ENABLED", "1") != "0"
if media_enabled:
tunnel = MediaTunnelClient(
api_base=api.base,
business_id=api.business_id,
pbx_id=api.pbx_id,
token=api.token,
on_json=on_tunnel_json,
on_pcm=on_tunnel_pcm,
)
tunnel_ref["client"] = tunnel
tunnel.start()
LOG.info("Media tunnel starter → %s", tunnel.ws_url)
else:
LOG.warning("MEDIA_TUNNEL_ENABLED=0 — فقط CTI بدون Softphone Relay")
LOG.info("Connector آماده است (AMI + Hesabix + Softphone media).")
last_hb = 0.0
last_poll = 0.0
last_media_ping = 0.0
try:
for ev in ami.read_events():
now = time.time()
if now - last_hb > 60:
api.heartbeat(status="ok", media_tunnel=bool(tunnel_ref.get("client") and tunnel_ref["client"].connected))
last_hb = now
# ping رسانه جدا از heartbeat حسابیکس — تا tunnel_online سمت API پایدار بماند
if now - last_media_ping > 15:
client = tunnel_ref.get("client")
if client and client.connected:
client.send_json({"type": "ping", "ts": int(now)})
last_media_ping = now
if now - last_poll > 2:
for cmd in api.poll_commands():
cid = cmd.get("command_id")
ctype = cmd.get("type")
try:
if ctype == "originate":
ami.originate(
extension=str(cmd.get("extension")),
destination=str(cmd.get("destination")),
context=str(cmd.get("context") or "from-internal"),
timeout_ms=int(cmd.get("timeout_ms") or 30000),
)
elif ctype == "softphone_bridge_out":
destination = str(cmd.get("destination"))
context = str(cmd.get("context") or "from-internal")
as_uuid = str(cmd.get("audiosocket_uuid") or uuid.uuid4())
session_id = str(cmd.get("session_id") or "")
host = str(cmd.get("audiosocket_host") or audio_host)
port = int(cmd.get("audiosocket_port") or audio_port)
with media_lock:
if session_id:
as_uuid_to_session[as_uuid] = session_id
session_to_as_uuid[session_id] = as_uuid
if cmd.get("call_id") is not None:
session_to_call_id[session_id] = cmd.get("call_id")
# Asterisk AudioSocket(uuid,host:port) — ترتیب آرگومان مهم است.
# /n روی Local مانع channel optimization می‌شود تا AudioSocket قطع نشود.
ami.originate_to_application(
channel=f"Local/{destination}@{context}/n",
application="AudioSocket",
data=f"{as_uuid},{host}:{port}",
caller_id=str(cmd.get("extension") or ""),
timeout_ms=int(cmd.get("timeout_ms") or 30000),
variable=f"HSX_UUID={as_uuid}",
)
LOG.info(
"softphone_bridge_out dest=%s uuid=%s as=%s:%s",
destination,
as_uuid,
host,
port,
)
# bridge.active بعد از TCP AudioSocket (on_as_connect) تا میکروفون/سکوت به‌موقع برسد
if audio_server.has_connection(as_uuid):
_notify_bridge_active(session_id, as_uuid, call_id=cmd.get("call_id"))
elif ctype == "softphone_bridge_in":
as_uuid = str(cmd.get("audiosocket_uuid") or uuid.uuid4())
session_id = str(cmd.get("session_id") or "")
channel = cmd.get("channel")
host = str(cmd.get("audiosocket_host") or audio_host)
port = int(cmd.get("audiosocket_port") or audio_port)
with media_lock:
if session_id:
as_uuid_to_session[as_uuid] = session_id
session_to_as_uuid[session_id] = as_uuid
if channel:
# انتقال کانال زنگ‌خور به AudioSocket — UUID را روی کانال ست کن
ami.set_var(str(channel), "HSX_UUID", as_uuid)
ami.redirect(str(channel), "s", context="hesabix-softphone-relay")
else:
ext = str(cmd.get("extension") or "")
ami.originate_to_application(
channel=f"Local/{ext}@from-internal/n",
application="AudioSocket",
data=f"{as_uuid},{host}:{port}",
caller_id=ext,
timeout_ms=30000,
variable=f"HSX_UUID={as_uuid}",
)
if audio_server.has_connection(as_uuid):
_notify_bridge_active(session_id, as_uuid, call_id=cmd.get("call_id"))
elif ctype == "softphone_dtmf":
channel = cmd.get("channel")
digit = str(cmd.get("digit") or "")
if channel and digit:
ami.play_dtmf(str(channel), digit)
elif ctype == "hangup":
channel = cmd.get("channel")
if channel:
ami.hangup(str(channel))
session_id = str(cmd.get("session_id") or "")
as_uuid = str(cmd.get("audiosocket_uuid") or "")
with media_lock:
if not as_uuid and session_id:
as_uuid = session_to_as_uuid.pop(session_id, "") or ""
elif as_uuid:
mapped_session = as_uuid_to_session.pop(as_uuid, "") or ""
session_id = session_id or mapped_session
if session_id:
session_to_as_uuid.pop(session_id, None)
if as_uuid:
as_uuid_to_session.pop(as_uuid, None)
if session_id:
session_to_bridge_id.pop(session_id, None)
if as_uuid:
LOG.info("hangup AudioSocket uuid=%s session=%s", as_uuid, session_id)
audio_server.hangup(as_uuid)
elif not channel:
LOG.warning("hangup without channel/session/uuid cmd=%s", cmd)
elif ctype == "transfer":
channel = cmd.get("channel")
target = cmd.get("target")
if channel and target:
ami.redirect(str(channel), str(target), context=str(cmd.get("context") or "from-internal"))
elif ctype in ("hold", "resume"):
channel = cmd.get("channel")
if channel and ctype == "hold":
ami.park_or_hold(str(channel))
if cid:
api.ack(str(cid), accepted=True)
except Exception as e:
LOG.exception("command failed: %s", ctype)
if cid:
api.ack(str(cid), accepted=False, error=str(e))
last_poll = now
mapped = map_ami_event(ev)
if mapped:
result = api.send_event(mapped)
call_obj = result.get("call") if isinstance(result, dict) else None
call_id = None
if isinstance(call_obj, dict):
call_id = call_obj.get("id")
elif isinstance(result, dict):
call_id = result.get("call_id")
# زنگ Softphone وب وقتی داخلی Relay آنلاین است
if mapped.get("softphone_relay"):
ext = str(mapped.get("extension") or "").strip()
with media_lock:
session_id = online_agents_by_ext.get(ext)
client = tunnel_ref.get("client")
if session_id and client:
LOG.info(
"inbound softphone ring ext=%s session=%s channel=%s from=%s call_id=%s",
ext,
session_id,
mapped.get("channel"),
mapped.get("from"),
call_id,
)
client.send_json(
{
"type": "incoming_softphone_ring",
"session_id": session_id,
"extension": ext,
"from": mapped.get("from"),
"channel": mapped.get("channel"),
"uniqueid": mapped.get("uniqueid"),
"linkedid": mapped.get("linkedid"),
"call_id": call_id,
}
)
except KeyboardInterrupt:
LOG.info("Stopping…")
except Exception as e:
LOG.exception("حلقه رویداد قطع شد: %s", e)
return 1
finally:
client = tunnel_ref.get("client")
if client:
client.stop()
audio_server.stop()
ami.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())