Files
ACORE/apps/asterisk_connector/ami.py
T

155 lines
4.4 KiB
Python

"""Минимальный клиент Asterisk Manager Interface без сторонних пакетов."""
from __future__ import annotations
import logging
import socket
import ssl
logger = logging.getLogger(__name__)
DEFAULT_PORT = 5038
MAX_PACKET = 65536
class AmiError(Exception):
"""Ошибка протокола или соединения AMI."""
def _encode_action(action: str, fields: dict) -> bytes:
lines = [f'Action: {action}']
for key, value in fields.items():
if value is None or value == '':
continue
lines.append(f'{key}: {value}')
lines.append('')
lines.append('')
return '\r\n'.join(lines).encode('utf-8')
def parse_ami_packet(text: str) -> dict:
packet = {}
for line in text.split('\r\n'):
if not line or ':' not in line:
continue
key, value = line.split(':', 1)
packet[key.strip()] = value.strip()
return packet
class AmiClient:
def __init__(
self,
host: str,
port: int,
username: str,
secret: str,
*,
timeout: float = 8,
use_ssl: bool = False,
):
self.host = host
self.port = int(port or DEFAULT_PORT)
self.username = username
self.secret = secret
self.timeout = timeout
self.use_ssl = use_ssl
self._sock = None
def connect(self):
raw = socket.create_connection((self.host, self.port), timeout=self.timeout)
if self.use_ssl:
ctx = ssl.create_default_context()
raw = ctx.wrap_socket(raw, server_hostname=self.host)
self._sock = raw
banner = self._read_until(b'\r\n')
if b'Asterisk' not in banner and b'Call Manager' not in banner:
logger.warning('Unexpected AMI banner: %s', banner[:120])
def close(self):
if self._sock is None:
return
try:
self._sock.close()
except OSError:
pass
self._sock = None
def __enter__(self):
self.connect()
self.login()
return self
def __exit__(self, *exc):
try:
if self._sock is not None:
self.send_action('Logoff')
except AmiError:
pass
self.close()
def login(self) -> dict:
reply = self.send_action('Login', Username=self.username, Secret=self.secret)
if reply.get('Response') != 'Success':
raise AmiError(reply.get('Message') or 'AMI login failed')
return reply
def ping(self) -> dict:
reply = self.send_action('Ping')
if reply.get('Response') != 'Success':
raise AmiError(reply.get('Message') or 'AMI ping failed')
return reply
def originate(
self,
*,
channel: str,
exten: str,
context: str,
caller_id: str = '',
timeout_ms: int = 30000,
async_orig: bool = True,
) -> dict:
reply = self.send_action(
'Originate',
Channel=channel,
Context=context,
Exten=exten,
Priority='1',
CallerID=caller_id,
Timeout=str(timeout_ms),
Async='true' if async_orig else 'false',
)
if reply.get('Response') != 'Success':
raise AmiError(reply.get('Message') or 'AMI originate failed')
return reply
def extension_state(self, exten: str, context: str) -> dict:
return self.send_action('ExtensionState', Exten=exten, Context=context)
def send_action(self, action: str, **fields) -> dict:
if self._sock is None:
raise AmiError('AMI is not connected')
self._sock.sendall(_encode_action(action, fields))
packet = self._read_packet()
if not packet:
raise AmiError('Empty AMI response')
return parse_ami_packet(packet.decode('utf-8', errors='replace'))
def _read_until(self, marker: bytes) -> bytes:
buf = b''
while marker not in buf:
chunk = self._sock.recv(4096)
if not chunk:
break
buf += chunk
if len(buf) > MAX_PACKET:
raise AmiError('AMI packet too large')
return buf
def _read_packet(self) -> bytes:
buf = self._read_until(b'\r\n\r\n')
idx = buf.find(b'\r\n\r\n')
if idx == -1:
return buf
return buf[:idx]