import re from urllib.parse import unquote, urlparse from typing import Optional from .base import CallMetadata, BaseEmailParser NOVOFON_URL_PATTERN = re.compile( r"(https://(?:my|app)\.novofon\.ru/system/media/talk/\d+/[a-f0-9]+[^\s<>\"']*)" ) class NovofonParser(BaseEmailParser): def extract_audio_url(self, body_text: str, body_html: str) -> Optional[str]: for source in (body_text, body_html): if not source: continue m = NOVOFON_URL_PATTERN.search(source) if m: return m.group(1).rstrip("/") + "/" return None def parse_url(self, url: str) -> Optional[CallMetadata]: meta = CallMetadata(audio_url=url) path = urlparse(url).path parts = path.rstrip("/").split("/") if len(parts) >= 3: meta.call_id = parts[-2] filename = unquote(parts[-1]) if parts else "" if filename: self._parse_filename(filename, meta) return meta def parse_email(self, subject: str, body_text: str, body_html: str) -> Optional[CallMetadata]: audio_url = self.extract_audio_url(body_text, body_html) if not audio_url: return None meta = self.parse_url(audio_url) # Try to parse metadata from email body self._parse_email_body(body_text, meta) self._parse_email_body(body_html, meta) self._parse_subject(subject, meta) return meta def _parse_subject(self, subject: str, meta: CallMetadata): if "исходящ" in subject.lower(): meta.direction = "outbound" elif "входящ" in subject.lower(): meta.direction = "inbound" def _parse_email_body(self, body: str, meta: CallMetadata): if not body: return from_number = None to_number = None employee_name = None duration = None duration_unit = None m = re.search(r"От:\s*(\+?\d[\d\s\-]*)", body) if m: from_number = self._clean_number(m.group(1)) m = re.search(r"(?:На номер|на номер|на номере)\s*(\+?\d[\d\s\-]*)", body) if m: to_number = self._clean_number(m.group(1)) m = re.search(r"Сотрудник\s*[-–—]\s*(.+?)(?:\s*(?:Продолжительность|Длительность)\s*|$)", body) if m: employee_name = m.group(1).strip() m = re.search(r"(?:Продолжительность|Длительность)\s*звонка\s*[-–—]\s*(\d+)\s*(сек|с|мин|м)", body) if m: duration = int(m.group(1)) duration_unit = m.group(2) if duration_unit in ("мин", "м"): duration *= 60 meta.duration_sec = duration if not meta.caller_number: is_outbound = "исходящ" in body.lower() if is_outbound: meta.caller_number = from_number meta.callee_number = to_number meta.direction = "outbound" else: meta.caller_number = from_number meta.callee_number = to_number meta.direction = "inbound" if not meta.employee and employee_name: meta.employee = employee_name def parse_attachment(self, subject: str, filename: str) -> Optional[CallMetadata]: meta = CallMetadata(audio_filename=filename) m = re.search(r"(\d+)-(\d+)\.\d+-(\d+)", filename) if m: meta.call_id = m.group(2) meta.callee_number = f"{m.group(1)}-{m.group(3)}" m = re.search(r"(\d{4}-\d{2}-\d{2})-(\d{6})", filename) if m: meta.datetime = f"{m.group(1)}T{m.group(2)[:2]}:{m.group(2)[2:4]}:{m.group(2)[4:]}Z" self._parse_subject(subject, meta) if not meta.direction: meta.direction = "inbound" return meta def _parse_filename(self, filename: str, meta: CallMetadata): m = re.search(r"От:\s*(\+?\d[\d\s\-]*)", filename) if m: meta.caller_number = self._clean_number(m.group(1)) m = re.search(r"На номер\s*(\+?\d[\d\s\-]*)", filename) if m: meta.callee_number = self._clean_number(m.group(1)) m = re.search(r"Сотрудник\s*-\s*(.+?)(?:Длительность|$)", filename) if m: meta.employee = m.group(1).strip() m = re.search(r"Длительность звонка\s*-\s*(\d+)\s*сек", filename) if m: meta.duration_sec = int(m.group(1)) m = re.search(r"(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})", filename) if m: meta.datetime = f"{m.group(1)}T{m.group(2).replace('-', ':')}Z" if meta.caller_number and meta.callee_number: meta.direction = "inbound" else: m = re.search(r"from_(\d+)_to_(\d+)", filename) if m: meta.caller_number = self._clean_number(m.group(1)) meta.callee_number = self._clean_number(m.group(2)) meta.direction = "inbound" @staticmethod def _clean_number(raw: str) -> str: return "+" + re.sub(r"\D", "", raw) PARSER_REGISTRY = { "novofon": NovofonParser, } def get_parser(name: str = "novofon") -> BaseEmailParser: cls = PARSER_REGISTRY.get(name) if not cls: raise ValueError(f"Unknown parser: {name}, available: {list(PARSER_REGISTRY.keys())}") return cls()