Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,849 @@
# -*- 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))