# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import json import logging import socket import time from datetime import datetime, timedelta import requests from odoo import api, fields, models, _ from odoo.exceptions import UserError RC_RATE_LIMIT_DELAY = 10 RC_RATE_LIMIT_RETRY_WAIT = 65 RC_MAX_RETRIES = 5 _logger = logging.getLogger(__name__) RC_EMBEDDABLE_REDIRECT = ( 'https://apps.ringcentral.com/integration/ringcentral-embeddable/latest/redirect.html' ) class RcConfig(models.Model): _name = 'rc.config' _description = 'RingCentral Configuration' _rec_name = 'name' name = fields.Char(string='Configuration Name', required=True, default='RingCentral') client_id = fields.Char(string='Client ID', required=True, groups='fusion_ringcentral.group_rc_manager') client_secret = fields.Char(string='Client Secret', required=True, groups='fusion_ringcentral.group_rc_manager') server_url = fields.Char( string='Server URL', required=True, default='https://platform.ringcentral.com', ) access_token = fields.Char(string='Access Token', groups='base.group_system') refresh_token = fields.Char(string='Refresh Token', groups='base.group_system') token_expiry = fields.Datetime(string='Token Expires At', groups='base.group_system') webhook_subscription_id = fields.Char(string='Webhook Subscription ID', readonly=True) state = fields.Selection([ ('draft', 'Not Connected'), ('connected', 'Connected'), ('error', 'Error'), ], string='Status', default='draft', required=True, tracking=True) phone_widget_enabled = fields.Boolean(string='Enable Phone Widget', default=True) proxy_url = fields.Char(string='HTTP Proxy URL', help='Optional proxy for enterprise networks.') proxy_port = fields.Char(string='Proxy Port') ssl_verify = fields.Boolean(string='Verify SSL Certificates', default=True) extension_name = fields.Char(string='Connected As', readonly=True) company_id = fields.Many2one('res.company', default=lambda self: self.env.company) # ────────────────────────────────────────────────────────── # Connection test # ────────────────────────────────────────────────────────── def action_test_connection(self): """Test DNS + HTTPS connectivity to the RingCentral server.""" self.ensure_one() results = [] # DNS check hostname = self.server_url.replace('https://', '').replace('http://', '').rstrip('/') try: socket.getaddrinfo(hostname, 443) results.append(f'DNS: {hostname} resolved OK') except socket.gaierror: return self._notify('Connection Failed', f'DNS resolution failed for {hostname}', 'danger') # HTTPS check try: resp = requests.get( f'{self.server_url}/restapi/v1.0', timeout=10, verify=self.ssl_verify, proxies=self._get_proxies(), ) results.append(f'HTTPS: Status {resp.status_code}') except requests.RequestException as e: return self._notify('Connection Failed', f'HTTPS error: {e}', 'danger') return self._notify('Connection Successful', '\n'.join(results), 'success') # ────────────────────────────────────────────────────────── # OAuth flow # ────────────────────────────────────────────────────────── def action_oauth_connect(self): """Redirect user to RingCentral OAuth authorization page.""" self.ensure_one() base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '') redirect_uri = f'{base_url}/ringcentral/oauth' state = json.dumps({'config_id': self.id}) auth_url = ( f'{self.server_url}/restapi/oauth/authorize' f'?response_type=code' f'&client_id={self.client_id}' f'&redirect_uri={redirect_uri}' f'&state={state}' ) return { 'type': 'ir.actions.act_url', 'url': auth_url, 'target': 'self', } def action_disconnect(self): """Revoke tokens and disconnect.""" self.ensure_one() if self.access_token: try: requests.post( f'{self.server_url}/restapi/oauth/revoke', data={'token': self.access_token}, auth=(self.client_id, self.client_secret), timeout=10, verify=self.ssl_verify, proxies=self._get_proxies(), ) except Exception: _logger.exception("Error revoking RingCentral token") self.write({ 'access_token': False, 'refresh_token': False, 'token_expiry': False, 'webhook_subscription_id': False, 'state': 'draft', 'extension_name': False, }) return self._notify('Disconnected', 'RingCentral has been disconnected.', 'warning') def exchange_auth_code(self, code, redirect_uri): """Exchange authorization code for access/refresh tokens.""" self.ensure_one() try: resp = requests.post( f'{self.server_url}/restapi/oauth/token', data={ 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirect_uri, }, auth=(self.client_id, self.client_secret), timeout=15, verify=self.ssl_verify, proxies=self._get_proxies(), ) resp.raise_for_status() data = resp.json() expires_in = data.get('expires_in', 3600) self.write({ 'access_token': data.get('access_token'), 'refresh_token': data.get('refresh_token'), 'token_expiry': datetime.utcnow() + timedelta(seconds=expires_in), 'state': 'connected', }) self._fetch_extension_info() self._create_webhook_subscription() return True except Exception: _logger.exception("RingCentral OAuth token exchange failed") self.write({'state': 'error'}) return False def _refresh_token(self): """Refresh the access token using the refresh token.""" self.ensure_one() if not self.refresh_token: self.write({'state': 'error'}) return False try: resp = requests.post( f'{self.server_url}/restapi/oauth/token', data={ 'grant_type': 'refresh_token', 'refresh_token': self.refresh_token, }, auth=(self.client_id, self.client_secret), timeout=15, verify=self.ssl_verify, proxies=self._get_proxies(), ) resp.raise_for_status() data = resp.json() expires_in = data.get('expires_in', 3600) self.write({ 'access_token': data.get('access_token'), 'refresh_token': data.get('refresh_token', self.refresh_token), 'token_expiry': datetime.utcnow() + timedelta(seconds=expires_in), 'state': 'connected', }) return True except Exception: _logger.exception("RingCentral token refresh failed") self.write({'state': 'error'}) return False def _ensure_token(self): """Ensure we have a valid access token, refreshing if needed.""" self.ensure_one() if not self.access_token: raise UserError(_('RingCentral is not connected. Please connect via OAuth first.')) if self.token_expiry and self.token_expiry < fields.Datetime.now(): if not self._refresh_token(): raise UserError(_('Failed to refresh RingCentral token. Please reconnect.')) def _get_headers(self): """Return authorization headers for API calls.""" self._ensure_token() return { 'Authorization': f'Bearer {self.access_token}', 'Content-Type': 'application/json', } def _api_get(self, endpoint, params=None): """Make an authenticated GET request with rate-limit handling.""" return self._api_request('GET', endpoint, params=params) def _api_post(self, endpoint, data=None): """Make an authenticated POST request with rate-limit handling.""" return self._api_request('POST', endpoint, json_data=data) def _api_request(self, method, endpoint, params=None, json_data=None): """Central API request method with automatic rate-limit retry. RingCentral Heavy group (Call Log): 10 req / 60 sec, 60 sec penalty. We pace at 10 sec between calls (~6 req/min) and auto-retry on 429 or 401 (token can appear invalid during penalty window). """ self._ensure_token() url = endpoint if endpoint.startswith('http') else f'{self.server_url}{endpoint}' kwargs = { 'timeout': 30, 'verify': self.ssl_verify, 'proxies': self._get_proxies(), } if params: kwargs['params'] = params if json_data is not None: kwargs['json'] = json_data last_error = None for attempt in range(1, RC_MAX_RETRIES + 1): kwargs['headers'] = self._get_headers() resp = requests.request(method, url, **kwargs) if resp.status_code == 429: retry_after = int(resp.headers.get('Retry-After', RC_RATE_LIMIT_RETRY_WAIT)) _logger.warning( "RC rate limit 429. Waiting %d sec (attempt %d/%d)...", retry_after, attempt, RC_MAX_RETRIES, ) time.sleep(retry_after) self._ensure_token() continue if resp.status_code == 401 and attempt < RC_MAX_RETRIES: _logger.warning( "RC 401 Unauthorized -- token may be stale from rate-limit penalty. " "Refreshing token and waiting 60 sec (attempt %d/%d)...", attempt, RC_MAX_RETRIES, ) time.sleep(60) try: self._refresh_token() except Exception: pass continue if resp.status_code >= 400: last_error = resp _logger.error( "RC API error %d on %s %s: %s", resp.status_code, method, url, resp.text[:300], ) resp.raise_for_status() time.sleep(RC_RATE_LIMIT_DELAY) return resp.json() if last_error is not None: last_error.raise_for_status() raise UserError(_('RingCentral API request failed after %d attempts.') % RC_MAX_RETRIES) # ────────────────────────────────────────────────────────── # Extension info # ────────────────────────────────────────────────────────── def _fetch_extension_info(self): """Fetch the connected user's extension info.""" self.ensure_one() try: data = self._api_get('/restapi/v1.0/account/~/extension/~') self.extension_name = data.get('name', 'Unknown') except Exception: _logger.exception("Failed to fetch extension info") # ────────────────────────────────────────────────────────── # Webhook management # ────────────────────────────────────────────────────────── def _create_webhook_subscription(self): """Create a webhook subscription for telephony events.""" self.ensure_one() base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '') webhook_url = f'{base_url}/ringcentral/webhook' try: data = self._api_post('/restapi/v1.0/subscription', data={ 'eventFilters': [ '/restapi/v1.0/account/~/extension/~/telephony/sessions', ], 'deliveryMode': { 'transportType': 'WebHook', 'address': webhook_url, }, 'expiresIn': 630720000, }) self.webhook_subscription_id = data.get('id', '') _logger.info("RingCentral webhook subscription created: %s", self.webhook_subscription_id) except Exception: _logger.exception("Failed to create RingCentral webhook subscription") def _renew_webhook_subscription(self): """Renew the webhook subscription.""" self.ensure_one() if not self.webhook_subscription_id: self._create_webhook_subscription() return try: self._api_post( f'/restapi/v1.0/subscription/{self.webhook_subscription_id}/renew' ) _logger.info("RingCentral webhook renewed: %s", self.webhook_subscription_id) except Exception: _logger.warning("Webhook renewal failed, recreating...") self._create_webhook_subscription() # ────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────── def _get_proxies(self): """Return proxy dict for requests if configured.""" if self.proxy_url: proxy = f'{self.proxy_url}:{self.proxy_port}' if self.proxy_port else self.proxy_url return {'http': proxy, 'https': proxy} return None def _notify(self, title, message, level='info'): return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _(title), 'message': message, 'type': level, 'sticky': level == 'danger', }, } @api.model def _get_active_config(self): """Return the first connected config, or False.""" return self.search([('state', '=', 'connected')], limit=1) def action_rematch_contacts(self): """Re-run contact matching on all calls without a linked partner.""" self.ensure_one() CallHistory = self.env['rc.call.history'] unlinked = CallHistory.search([('partner_id', '=', False)]) matched = 0 for call in unlinked: partner = CallHistory._match_partner( call.from_number or '', call.to_number or '', call.direction, ) if partner: call.write({'partner_id': partner.id}) matched += 1 return self._notify( 'Contact Matching Complete', f'Matched {matched} calls out of {len(unlinked)} unlinked records.', 'success' if matched else 'info', ) def action_import_historical_calls(self): """Trigger historical call import in the background via cron. Returns immediately so the UI doesn't freeze. The actual work happens in _run_historical_import() called by a one-shot cron. """ self.ensure_one() self._ensure_token() cron = self.env.ref( 'fusion_ringcentral.cron_rc_historical_import', raise_if_not_found=False, ) if cron: cron.sudo().write({ 'active': True, 'nextcall': fields.Datetime.now(), }) else: self.env['ir.cron'].sudo().create({ 'name': 'RingCentral: Historical Import', 'cron_name': 'RingCentral: Historical Import', 'model_id': self.env['ir.model']._get('rc.config').id, 'state': 'code', 'code': 'model._run_historical_import()', 'interval_number': 9999, 'interval_type': 'days', 'nextcall': fields.Datetime.now(), 'active': True, }) return self._notify( 'Import Started', 'Historical call import is running in the background. ' 'Check Fusion RingCentral > Call History in a few minutes to see results.', 'info', ) def action_import_historical_voicemails(self): """Trigger historical voicemail import in the background via cron.""" self.ensure_one() self._ensure_token() cron = self.env.ref( 'fusion_ringcentral.cron_rc_historical_vm_import', raise_if_not_found=False, ) if cron: cron.sudo().write({ 'active': True, 'nextcall': fields.Datetime.now(), }) else: self.env['ir.cron'].sudo().create({ 'name': 'RingCentral: Historical Voicemail Import', 'cron_name': 'RingCentral: Historical Voicemail Import', 'model_id': self.env['ir.model']._get('rc.voicemail').id, 'state': 'code', 'code': 'model._run_historical_voicemail_import()', 'interval_number': 9999, 'interval_type': 'days', 'nextcall': fields.Datetime.now(), 'active': True, }) return self._notify( 'Voicemail Import Started', 'Historical voicemail import is running in the background. ' 'Check Fusion RingCentral > Voicemails in a few minutes.', 'info', ) def action_import_historical_faxes(self): """Trigger historical fax import in the background via cron.""" self.ensure_one() self._ensure_token() cron = self.env.ref( 'fusion_ringcentral.cron_rc_historical_fax_import', raise_if_not_found=False, ) if cron: cron.sudo().write({ 'active': True, 'nextcall': fields.Datetime.now(), }) else: self.env['ir.cron'].sudo().create({ 'name': 'RingCentral: Historical Fax Import', 'cron_name': 'RingCentral: Historical Fax Import', 'model_id': self.env['ir.model']._get('fusion.fax').id, 'state': 'code', 'code': 'model._run_historical_fax_import()', 'interval_number': 9999, 'interval_type': 'days', 'nextcall': fields.Datetime.now(), 'active': True, }) return self._notify( 'Fax Import Started', 'Historical fax import (12 months) is running in the background. ' 'Check Fusion Connect > Faxes in a few minutes.', 'info', ) def action_backfill_voicemail_media(self): """Re-download audio and transcriptions for voicemails missing them.""" self.ensure_one() self._ensure_token() cron = self.env.ref( 'fusion_ringcentral.cron_rc_backfill_vm_media', raise_if_not_found=False, ) if cron: cron.sudo().write({ 'active': True, 'nextcall': fields.Datetime.now(), }) else: self.env['ir.cron'].sudo().create({ 'name': 'RingCentral: Backfill Voicemail Media', 'cron_name': 'RingCentral: Backfill Voicemail Media', 'model_id': self.env['ir.model']._get('rc.voicemail').id, 'state': 'code', 'code': 'model._run_backfill_voicemail_media()', 'interval_number': 9999, 'interval_type': 'days', 'nextcall': fields.Datetime.now(), 'active': True, }) return self._notify( 'Voicemail Media Backfill Started', 'Downloading audio and transcriptions for voicemails that are missing them. ' 'This runs in the background with rate-limit pacing.', 'info', ) @api.model def _run_historical_import(self): """Background job: import up to 12 months of call history. Tracks which monthly chunks completed successfully via ir.config_parameter so it never re-queries months that already finished. Only fetches chunks that failed or haven't been attempted yet. """ config = self._get_active_config() if not config: _logger.warning("RC Historical Import: No connected config found.") return try: config._ensure_token() except Exception: _logger.exception("RC Historical Import: Token invalid, aborting.") return ICP = self.env['ir.config_parameter'].sudo() CallHistory = self.env['rc.call.history'] now = datetime.utcnow() total_imported = 0 total_skipped_chunks = 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.import_done_{chunk_start.strftime("%Y%m")}' already_done = ICP.get_param(chunk_key, '') if already_done: _logger.info( "RC Import [%d/12]: %s to %s -- already imported, skipping.", chunk_num, date_from[:10], date_to[:10], ) total_skipped_chunks += 1 continue _logger.info( "RC Import [%d/12]: %s to %s ...", chunk_num, date_from[:10], date_to[:10], ) try: chunk_count = CallHistory._sync_calls_from_date(config, date_from, date_to) total_imported += chunk_count ICP.set_param(chunk_key, fields.Datetime.now().isoformat()) _logger.info( "RC Import [%d/12]: %d calls imported.", chunk_num, chunk_count, ) except Exception: failed_chunks += 1 _logger.exception( "RC Import [%d/12]: Failed, will retry next run.", chunk_num, ) ICP.set_param( 'fusion_rc.last_call_sync', now.strftime('%Y-%m-%dT%H:%M:%S.000Z'), ) _logger.info( "RC Historical Import complete: %d imported, %d chunks skipped (already done), %d failed.", total_imported, total_skipped_chunks, failed_chunks, ) # ────────────────────────────────────────────────────────── # Cron: token refresh # ────────────────────────────────────────────────────────── @api.model def _cron_refresh_tokens(self): """Refresh tokens for all connected configs nearing expiry.""" soon = fields.Datetime.now() + timedelta(minutes=10) configs = self.search([ ('state', '=', 'connected'), ('token_expiry', '<=', soon), ('refresh_token', '!=', False), ]) for cfg in configs: try: cfg._refresh_token() except Exception: _logger.exception("Cron: Failed to refresh token for config %s", cfg.name) # ────────────────────────────────────────────────────────── # Cron: webhook renewal # ────────────────────────────────────────────────────────── @api.model def _cron_renew_webhooks(self): """Renew webhook subscriptions for all connected configs.""" configs = self.search([('state', '=', 'connected')]) for cfg in configs: try: cfg._renew_webhook_subscription() except Exception: _logger.exception("Cron: Failed to renew webhook for config %s", cfg.name)