telephony: автоформат +380, тип линии SIP/АТС и звонок через Asterisk AMI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""Минимальный клиент 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]
|
||||
Reference in New Issue
Block a user