5d06d4d3ce
Co-authored-by: Cursor <cursoragent@cursor.com>
155 lines
4.4 KiB
Python
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]
|