850 lines
32 KiB
Python
850 lines
32 KiB
Python
# -*- 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))
|