"""Минимальный клиент 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]