# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import base64 import io import re import time import logging from datetime import datetime, timedelta import requests as py_requests from odoo import api, fields, models, _ RC_MEDIA_DELAY = 10 RC_MEDIA_RETRY_WAIT = 65 RC_MEDIA_MAX_RETRIES = 5 OPENAI_WHISPER_URL = 'https://api.openai.com/v1/audio/transcriptions' _logger = logging.getLogger(__name__) class RcVoicemail(models.Model): _name = 'rc.voicemail' _description = 'RingCentral Voicemail' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'received_date desc' _rec_name = 'name' name = fields.Char( string='Reference', required=True, copy=False, readonly=True, default=lambda self: _('New'), ) rc_message_id = fields.Char( string='RC Message ID', readonly=True, copy=False, index=True, ) caller_number = fields.Char(string='Caller Number', readonly=True) caller_name = fields.Char(string='Caller Name', readonly=True) direction = fields.Selection([ ('inbound', 'Inbound'), ('outbound', 'Outbound'), ], string='Direction', default='inbound', readonly=True) received_date = fields.Datetime(string='Received At', readonly=True) duration = fields.Integer(string='Duration (sec)', readonly=True) duration_display = fields.Char( string='Duration', compute='_compute_duration_display', ) read_status = fields.Selection([ ('Read', 'Read'), ('Unread', 'Unread'), ], string='Read Status', readonly=True) transcription_status = fields.Char( string='Transcription Status', readonly=True, ) transcription_text = fields.Text(string='Transcription', readonly=True) transcription_timeline = fields.Text( string='Timeline', readonly=True, help='Timestamped transcription with silence gaps.', ) transcription_language = fields.Char( string='Language', readonly=True, ) has_transcription = fields.Boolean( compute='_compute_has_transcription', store=True, ) partner_id = fields.Many2one( 'res.partner', string='Contact', tracking=True, ) audio_attachment_id = fields.Many2one( 'ir.attachment', string='Audio File', readonly=True, ) sale_order_count = fields.Integer(compute='_compute_partner_counts') invoice_count = fields.Integer(compute='_compute_partner_counts') @api.depends('duration') def _compute_duration_display(self): for rec in self: if rec.duration: mins, secs = divmod(rec.duration, 60) rec.duration_display = f'{mins}m {secs}s' if mins else f'{secs}s' else: rec.duration_display = '' @api.depends('transcription_text') def _compute_has_transcription(self): for rec in self: rec.has_transcription = bool(rec.transcription_text) @api.depends('partner_id') def _compute_partner_counts(self): for rec in self: if rec.partner_id: partner = rec.partner_id.commercial_partner_id or rec.partner_id partner_ids = (partner | partner.child_ids).ids rec.sale_order_count = self.env['sale.order'].search_count( [('partner_id', 'in', partner_ids)], ) rec.invoice_count = self.env['account.move'].search_count( [('partner_id', 'in', partner_ids), ('move_type', 'in', ('out_invoice', 'out_refund'))], ) else: rec.sale_order_count = 0 rec.invoice_count = 0 @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('name', _('New')) == _('New'): vals['name'] = ( self.env['ir.sequence'].next_by_code('rc.voicemail') or _('New') ) if not vals.get('partner_id') and vals.get('caller_number'): partner = self._match_voicemail_partner(vals['caller_number']) if partner: vals['partner_id'] = partner.id return super().create(vals_list) # ────────────────────────────────────────────────────────── # Contact matching # ────────────────────────────────────────────────────────── @api.model def _match_voicemail_partner(self, number): if not number: return False cleaned = self._normalize_phone(number) if not cleaned or len(cleaned) < 7: return False Partner = self.env['res.partner'] phone_fields = [f for f in ('phone', 'mobile') if f in Partner._fields] if not phone_fields: return False domain = ['|'] * (len(phone_fields) - 1) for f in phone_fields: domain.append((f, '!=', False)) partners = Partner.search(domain, order='customer_rank desc, write_date desc') for p in partners: for f in phone_fields: val = p[f] if val and self._normalize_phone(val) == cleaned: return p return False @staticmethod def _normalize_phone(number): return re.sub(r'\D', '', number or '')[-10:] if number else '' # ────────────────────────────────────────────────────────── # Smart button actions # ────────────────────────────────────────────────────────── def action_view_contact(self): self.ensure_one() if not self.partner_id: return return { 'type': 'ir.actions.act_window', 'res_model': 'res.partner', 'res_id': self.partner_id.id, 'view_mode': 'form', 'target': 'current', } def action_view_sale_orders(self): self.ensure_one() if not self.partner_id: return partner = self.partner_id.commercial_partner_id or self.partner_id partner_ids = (partner | partner.child_ids).ids return { 'type': 'ir.actions.act_window', 'name': _('Sales Orders - %s') % self.partner_id.name, 'res_model': 'sale.order', 'view_mode': 'list,form', 'domain': [('partner_id', 'in', partner_ids)], 'target': 'current', } def action_view_invoices(self): self.ensure_one() if not self.partner_id: return partner = self.partner_id.commercial_partner_id or self.partner_id partner_ids = (partner | partner.child_ids).ids return { 'type': 'ir.actions.act_window', 'name': _('Invoices - %s') % self.partner_id.name, 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [('partner_id', 'in', partner_ids), ('move_type', 'in', ('out_invoice', 'out_refund'))], 'target': 'current', } # ────────────────────────────────────────────────────────── # Call / Fax buttons (stay on same page via act_window_close) # ────────────────────────────────────────────────────────── def action_make_call(self): self.ensure_one() return { 'type': 'ir.actions.act_window_close', 'infos': { 'rc_action': 'call', 'rc_phone_number': self.caller_number or '', }, } def action_send_sms(self): self.ensure_one() return { 'type': 'ir.actions.act_window_close', 'infos': { 'rc_action': 'sms', 'rc_phone_number': self.caller_number or '', }, } def action_send_fax(self): self.ensure_one() ctx = {} if self.partner_id: ctx['default_partner_id'] = self.partner_id.id fax_num = getattr(self.partner_id, 'x_ff_fax_number', '') if fax_num: ctx['default_fax_number'] = fax_num return { 'type': 'ir.actions.act_window', 'name': _('Send Fax'), 'res_model': 'fusion_faxes.send.fax.wizard', 'view_mode': 'form', 'target': 'new', 'context': ctx, } # ────────────────────────────────────────────────────────── # Cron: incremental voicemail sync # ────────────────────────────────────────────────────────── @api.model def _cron_sync_voicemails(self): """Incremental sync: fetch voicemails since last sync.""" config = self.env['rc.config']._get_active_config() if not config: return ICP = self.env['ir.config_parameter'].sudo() last_sync = ICP.get_param('fusion_rc.last_voicemail_sync', '') if not last_sync: two_days_ago = datetime.utcnow() - timedelta(days=2) last_sync = two_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z') now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z') total = self._sync_voicemails_from_date(config, last_sync, now_str) ICP.set_param('fusion_rc.last_voicemail_sync', now_str) if total: _logger.info("RC Voicemail Sync: %d new voicemails imported.", total) @api.model def _cron_daily_voicemail_catchup(self): """Re-scan past 3 days for any missed voicemails.""" config = self.env['rc.config']._get_active_config() if not config: return three_days_ago = datetime.utcnow() - timedelta(days=3) date_from = three_days_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z') now_str = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z') total = self._sync_voicemails_from_date(config, date_from, now_str) _logger.info("RC Voicemail Catchup: %d new voicemails (past 3 days).", total) # ────────────────────────────────────────────────────────── # Backfill: re-download audio/transcripts for existing records # ────────────────────────────────────────────────────────── @api.model def _run_backfill_voicemail_media(self): """Re-fetch audio and transcription for records missing them. Called via cron after the initial import that missed audio due to rate limiting. Queries the RC API for each voicemail's message data and downloads any missing media. """ config = self.env['rc.config']._get_active_config() if not config: _logger.warning("RC VM Backfill: No connected config.") return try: config._ensure_token() except Exception: _logger.exception("RC VM Backfill: Token invalid.") return missing = self.search([ ('audio_attachment_id', '=', False), ('rc_message_id', '!=', False), ], order='received_date asc') _logger.info("RC VM Backfill: %d voicemails need audio.", len(missing)) success = 0 for vm in missing: try: data = config._api_get( f'/restapi/v1.0/account/~/extension/~/message-store/{vm.rc_message_id}', ) attachments = data.get('attachments', []) audio_uri = '' audio_content_type = 'audio/x-wav' transcript_uri = '' for att in attachments: att_type = att.get('type', '') if att_type == 'AudioRecording': audio_uri = att.get('uri', '') audio_content_type = att.get('contentType', 'audio/x-wav') elif att_type == 'AudioTranscription': transcript_uri = att.get('uri', '') if not vm.transcription_text and transcript_uri: text = self._download_transcription(transcript_uri, config) if text: vm.sudo().write({'transcription_text': text}) if audio_uri: self._download_and_attach_audio( vm, audio_uri, audio_content_type, config, ) success += 1 if not vm.transcription_text and vm.audio_attachment_id: self._try_openai_transcribe(vm) except Exception: _logger.exception( "RC VM Backfill: Failed for %s (msg %s).", vm.name, vm.rc_message_id, ) _logger.info( "RC VM Backfill complete: %d/%d audio files downloaded.", success, len(missing), ) # ────────────────────────────────────────────────────────── # Historical import (all available voicemails, 12 months) # ────────────────────────────────────────────────────────── @api.model def _run_historical_voicemail_import(self): """Background job: import up to 12 months of voicemails in monthly chunks. Tracks which monthly chunks completed successfully via ir.config_parameter so it never re-queries months already done. Rate-limit safe: _api_get already paces at ~6 req/min with auto-retry on 429. """ config = self.env['rc.config']._get_active_config() if not config: _logger.warning("RC VM Historical Import: No connected config.") return try: config._ensure_token() except Exception: _logger.exception("RC VM Historical Import: Token invalid.") return ICP = self.env['ir.config_parameter'].sudo() now = datetime.utcnow() total_imported = 0 total_skipped = 0 failed_chunks = 0 for months_back in range(12, 0, -1): chunk_start = now - timedelta(days=months_back * 30) chunk_end = now - timedelta(days=(months_back - 1) * 30) chunk_num = 13 - months_back date_from = chunk_start.strftime('%Y-%m-%dT%H:%M:%S.000Z') date_to = chunk_end.strftime('%Y-%m-%dT%H:%M:%S.000Z') chunk_key = ( f'fusion_rc.vm_import_done_{chunk_start.strftime("%Y%m")}' ) if ICP.get_param(chunk_key, ''): _logger.info( "RC VM Import [%d/12]: %s to %s -- already done, skipping.", chunk_num, date_from[:10], date_to[:10], ) total_skipped += 1 continue _logger.info( "RC VM Import [%d/12]: %s to %s ...", chunk_num, date_from[:10], date_to[:10], ) try: chunk_count = self._sync_voicemails_from_date( config, date_from, date_to, ) total_imported += chunk_count ICP.set_param(chunk_key, fields.Datetime.now().isoformat()) _logger.info( "RC VM Import [%d/12]: %d voicemails imported.", chunk_num, chunk_count, ) except Exception: failed_chunks += 1 _logger.exception( "RC VM Import [%d/12]: Failed, will retry next run.", chunk_num, ) _logger.info( "RC VM Historical Import complete: %d imported, %d skipped, %d failed.", total_imported, total_skipped, failed_chunks, ) # ────────────────────────────────────────────────────────── # Core sync logic # ────────────────────────────────────────────────────────── def _sync_voicemails_from_date(self, config, date_from, date_to=None): """Fetch voicemails from RC Message Store for a date range.""" total_imported = 0 try: params = { 'messageType': 'VoiceMail', 'dateFrom': date_from, 'perPage': 100, } if date_to: params['dateTo'] = date_to data = config._api_get( '/restapi/v1.0/account/~/extension/~/message-store', params=params, ) total_imported += self._process_voicemail_page(data, config) nav = data.get('navigation', {}) while nav.get('nextPage', {}).get('uri'): next_uri = nav['nextPage']['uri'] data = config._api_get(next_uri) total_imported += self._process_voicemail_page(data, config) nav = data.get('navigation', {}) except Exception: _logger.exception("RC Voicemail: Error syncing voicemails.") return total_imported def _process_voicemail_page(self, data, config): """Process one page of voicemail records with bulk dedup.""" records = data.get('records', []) if not records: return 0 page_ids = [str(r.get('id', '')) for r in records if r.get('id')] if page_ids: existing = set( r.rc_message_id for r in self.search([('rc_message_id', 'in', page_ids)]) ) else: existing = set() imported = 0 for msg in records: msg_id = str(msg.get('id', '')) if not msg_id or msg_id in existing: continue if self._import_voicemail(msg, config): existing.add(msg_id) imported += 1 return imported def _import_voicemail(self, msg, config): """Import a single voicemail message and download its audio.""" try: msg_id = str(msg.get('id', '')) from_info = msg.get('from', {}) caller_number = from_info.get('phoneNumber', '') caller_name = from_info.get('name', '') creation_time = msg.get('creationTime', '') received_dt = False if creation_time: try: clean = creation_time.replace('Z', '+00:00') received_dt = datetime.fromisoformat(clean).strftime( '%Y-%m-%d %H:%M:%S' ) except (ValueError, AttributeError): pass direction = (msg.get('direction', '') or '').lower() if direction not in ('inbound', 'outbound'): direction = 'inbound' attachments = msg.get('attachments', []) vm_duration = 0 audio_uri = '' audio_content_type = 'audio/x-wav' transcript_uri = '' for att in attachments: att_type = att.get('type', '') if att_type == 'AudioRecording': vm_duration = att.get('vmDuration', 0) audio_uri = att.get('uri', '') audio_content_type = att.get('contentType', 'audio/x-wav') elif att_type == 'AudioTranscription': transcript_uri = att.get('uri', '') transcription_text = '' if transcript_uri: transcription_text = self._download_transcription( transcript_uri, config, ) partner = self._match_voicemail_partner(caller_number) vm = self.sudo().create({ 'rc_message_id': msg_id, 'caller_number': caller_number, 'caller_name': caller_name, 'direction': direction, 'received_date': received_dt, 'duration': vm_duration, 'read_status': msg.get('readStatus', 'Unread'), 'transcription_status': 'Completed' if transcription_text else '', 'transcription_text': transcription_text, 'partner_id': partner.id if partner else False, }) if audio_uri: self._download_and_attach_audio(vm, audio_uri, audio_content_type, config) if not vm.transcription_text and vm.audio_attachment_id: self._try_openai_transcribe(vm) return True except Exception: _logger.exception( "RC Voicemail: Failed to import message %s", msg.get('id', ''), ) return False def _rc_media_get(self, uri, config): """Download binary content from RC with rate-limit retry.""" config._ensure_token() for attempt in range(1, RC_MEDIA_MAX_RETRIES + 1): resp = py_requests.get( uri, headers={'Authorization': f'Bearer {config.access_token}'}, timeout=30, verify=config.ssl_verify, proxies=config._get_proxies(), ) if resp.status_code == 429: wait = int(resp.headers.get('Retry-After', RC_MEDIA_RETRY_WAIT)) _logger.warning( "RC media 429. Waiting %ds (attempt %d/%d)...", wait, attempt, RC_MEDIA_MAX_RETRIES, ) time.sleep(wait) config._ensure_token() continue if resp.status_code == 401 and attempt < RC_MEDIA_MAX_RETRIES: _logger.warning( "RC media 401. Refreshing token, waiting 60s (attempt %d/%d)...", attempt, RC_MEDIA_MAX_RETRIES, ) time.sleep(60) try: config._refresh_token() except Exception: pass continue resp.raise_for_status() time.sleep(RC_MEDIA_DELAY) return resp resp.raise_for_status() def _download_transcription(self, transcript_uri, config): """Download voicemail transcription text from RC.""" try: resp = self._rc_media_get(transcript_uri, config) return (resp.text or '').strip() except Exception: _logger.warning("RC Voicemail: Could not fetch transcription.") return '' def _download_and_attach_audio(self, vm, audio_uri, content_type, config): """Download the voicemail audio and post it to the record's chatter.""" try: resp = self._rc_media_get(audio_uri, config) audio_data = resp.content if not audio_data: return ext = 'wav' if 'wav' in content_type else 'mp3' filename = f'{vm.name}.{ext}' attachment = self.env['ir.attachment'].sudo().create({ 'name': filename, 'type': 'binary', 'datas': base64.b64encode(audio_data), 'mimetype': content_type, 'res_model': 'rc.voicemail', 'res_id': vm.id, }) vm.sudo().write({'audio_attachment_id': attachment.id}) vm.sudo().message_post( body=_('Voicemail audio (%ss)') % vm.duration, attachment_ids=[attachment.id], message_type='notification', ) except Exception: _logger.exception( "RC Voicemail: Failed to download audio for %s", vm.name, ) # ────────────────────────────────────────────────────────── # OpenAI Whisper transcription # ────────────────────────────────────────────────────────── def _get_openai_key(self): return ( self.env['ir.config_parameter'] .sudo() .get_param('fusion_rc.openai_api_key', '') ) def _try_openai_transcribe(self, vm): """Transcribe a voicemail if OpenAI key is configured. Non-fatal.""" api_key = self._get_openai_key() if not api_key: return try: result = self._transcribe_audio_openai(vm.audio_attachment_id, api_key) if result: vm.sudo().write(result) except Exception: _logger.warning( "RC Voicemail: OpenAI transcription failed for %s", vm.name, ) def _transcribe_audio_openai(self, attachment, api_key): """Send audio to OpenAI Whisper verbose_json and return structured data. Returns a dict with transcription_text (English), language tag, and a timeline string showing silence gaps between segments. """ audio_data = base64.b64decode(attachment.datas) if not audio_data: return {} ext = 'wav' if attachment.mimetype and 'mp3' in attachment.mimetype: ext = 'mp3' resp = py_requests.post( OPENAI_WHISPER_URL, headers={'Authorization': f'Bearer {api_key}'}, files={ 'file': ( f'voicemail.{ext}', io.BytesIO(audio_data), attachment.mimetype or 'audio/wav', ), }, data={ 'model': 'whisper-1', 'response_format': 'verbose_json', 'timestamp_granularities[]': 'segment', }, timeout=120, ) resp.raise_for_status() data = resp.json() language = (data.get('language') or '').strip() full_text = (data.get('text') or '').strip() segments = data.get('segments', []) english_text = full_text if language and language != 'english': english_text = self._translate_to_english(full_text, api_key) timeline = self._build_timeline(segments) return { 'transcription_text': english_text, 'transcription_language': language.title() if language else '', 'transcription_timeline': timeline, 'transcription_status': 'Completed', } def _translate_to_english(self, text, api_key): """Translate text to English using OpenAI chat completions.""" try: resp = py_requests.post( 'https://api.openai.com/v1/chat/completions', headers={ 'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json', }, json={ 'model': 'gpt-4o-mini', 'messages': [ { 'role': 'system', 'content': 'Translate the following text to English. ' 'Return only the translation, nothing else.', }, {'role': 'user', 'content': text}, ], 'temperature': 0.1, }, timeout=60, ) resp.raise_for_status() return ( resp.json() .get('choices', [{}])[0] .get('message', {}) .get('content', text) .strip() ) except Exception: _logger.warning("RC Voicemail: Translation failed, using original.") return text @staticmethod def _build_timeline(segments): """Build a timeline string from Whisper segments showing silence gaps. Example output: .....3s..... Do you have apartments? .....6s..... Please call back. """ if not segments: return '' parts = [] prev_end = 0.0 for seg in segments: seg_start = seg.get('start', 0.0) seg_end = seg.get('end', 0.0) seg_text = (seg.get('text') or '').strip() gap = seg_start - prev_end if gap >= 1.0: parts.append(f'.....{int(gap)}s.....') if seg_text: parts.append(seg_text) prev_end = seg_end return ' '.join(parts) def action_transcribe(self): """Manual button: transcribe this voicemail with OpenAI Whisper.""" self.ensure_one() api_key = self._get_openai_key() if not api_key: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('No API Key'), 'message': _('Set your OpenAI API key in Settings > Fusion RingCentral.'), 'type': 'warning', 'sticky': False, }, } if not self.audio_attachment_id: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('No Audio'), 'message': _('This voicemail has no audio file to transcribe.'), 'type': 'warning', 'sticky': False, }, } try: result = self._transcribe_audio_openai(self.audio_attachment_id, api_key) if result: self.sudo().write(result) except Exception: _logger.exception("RC Voicemail: Manual transcription failed for %s", self.name) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Transcription Failed'), 'message': _('Could not transcribe. Check logs for details.'), 'type': 'danger', 'sticky': False, }, } @api.model def _cron_transcribe_voicemails(self): """Batch-transcribe voicemails that have audio but no transcript.""" api_key = self._get_openai_key() if not api_key: return pending = self.search([ ('audio_attachment_id', '!=', False), ('transcription_text', '=', False), ], limit=50, order='received_date desc') if not pending: return _logger.info("RC VM Transcribe: %d voicemails to process.", len(pending)) success = 0 for vm in pending: try: result = self._transcribe_audio_openai(vm.audio_attachment_id, api_key) if result: vm.sudo().write(result) success += 1 except Exception: _logger.warning( "RC VM Transcribe: Failed for %s, skipping.", vm.name, ) time.sleep(1) _logger.info("RC VM Transcribe: %d/%d completed.", success, len(pending))