Initial commit
This commit is contained in:
849
fusion_ringcentral/models/rc_voicemail.py
Normal file
849
fusion_ringcentral/models/rc_voicemail.py
Normal 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))
|
||||
Reference in New Issue
Block a user