# Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import requests import threading import json from odoo import _ from odoo.exceptions import RedirectWarning from odoo.addons.whatsapp.tools.whatsapp_exception import WhatsAppError _logger = logging.getLogger(__name__) DEFAULT_ENDPOINT = "https://graph.facebook.com/v17.0" class WhatsAppApi: def __init__(self, wa_account_id): wa_account_id.ensure_one() self.wa_account_id = wa_account_id self.phone_uid = wa_account_id.phone_uid self.token = wa_account_id.sudo().token self.is_shared_account = False def __api_requests(self, request_type, url, auth_type="", params=False, headers=None, data=False, files=False, endpoint_include=False): if getattr(threading.current_thread(), 'testing', False): raise WhatsAppError("API requests disabled in testing.") headers = headers or {} params = params or {} if not all([self.token, self.phone_uid]): action = self.wa_account_id.env.ref('whatsapp.whatsapp_account_action') raise RedirectWarning(_("To use WhatsApp Configure it first"), action=action.id, button_text=_("Configure Whatsapp Business Account")) if auth_type == 'oauth': headers.update({'Authorization': f'OAuth {self.token}'}) if auth_type == 'bearer': headers.update({'Authorization': f'Bearer {self.token}'}) call_url = (DEFAULT_ENDPOINT + url) if not endpoint_include else url try: res = requests.request(request_type, call_url, params=params, headers=headers, data=data, files=files, timeout=10) except requests.exceptions.RequestException: raise WhatsAppError(failure_type='network') # raise if json-parseable and 'error' in json try: if 'error' in res.json(): raise WhatsAppError(*self._prepare_error_response(res.json())) except ValueError: if not res.ok: raise WhatsAppError(failure_type='network') return res def _prepare_error_response(self, response): """ This method is used to prepare error response :return tuple[str, int]: (error_message, whatsapp_error_code | -1) """ if response.get('error'): error = response['error'] desc = error.get('message', '') desc += (' - ' + error['error_user_title']) if error.get('error_user_title') else '' desc += ('\n\n' + error['error_user_msg']) if error.get('error_user_msg') else '' code = error.get('code', 'odoo') return (desc if desc else _("Non-descript Error"), code) return (_("Something went wrong when contacting WhatsApp, please try again later. If this happens frequently, contact support."), -1) def _get_all_template(self, fetch_all=False): """ This method is used to get all the template from the WhatsApp Business Account API Documentation: https://developers.facebook.com/docs/graph-api/reference/whats-app-business-account/message_templates """ if self.is_shared_account: raise WhatsAppError(failure_type='account') template_url = f"/{self.wa_account_id.account_uid}/message_templates?fields=name,components,language,status,category,id,quality_score" _logger.info("Sync templates for account %s [%s]", self.wa_account_id.name, self.wa_account_id.id) if fetch_all: final_response_json = {} # Fetch 200 templates at once template_url += "&limit=200" endpoint_include = False while template_url: response = self.__api_requests("GET", url=template_url, auth_type="bearer", endpoint_include=endpoint_include) response_json = response.json() if final_response_json: # Add fetched data to existing response response_data = response_json.get("data", []) final_response_json.setdefault("data", []).extend(response_data) else: final_response_json = response_json # Fetch the next URL if it exists in response to fetch more templates template_url = response_json.get("paging", {}).get("next") endpoint_include = bool(template_url) else: response = self.__api_requests("GET", url=template_url, auth_type="bearer") final_response_json = response.json() return final_response_json def _get_template_data(self, wa_template_uid): """ This method is used to get one template details using template uid from the WhatsApp Business Account API Documentation: https://developers.facebook.com/docs/graph-api/reference/whats-app-business-account/message_templates """ if self.is_shared_account: raise WhatsAppError(failure_type='account') _logger.info("Get template details for template uid %s using account %s [%s]", wa_template_uid, self.wa_account_id.name, self.wa_account_id.id) response = self.__api_requests("GET", f"/{wa_template_uid}?fields=name,components,language,status,category,id,quality_score", auth_type="bearer") return response.json() def _upload_demo_document(self, attachment): """ This method is used to get a handle to later upload a demo document. Only use for template registration. API documentation https://developers.facebook.com/docs/graph-api/guides/upload """ if self.is_shared_account: raise WhatsAppError(failure_type='account') # Open session app_uid = self.wa_account_id.app_uid params = { 'file_length': attachment.file_size, 'file_type': attachment.mimetype, 'access_token': self.token, } _logger.info("Open template sample document upload session with file size %s Bites of mimetype %s on account %s [%s]", attachment.file_size, attachment.mimetype, self.wa_account_id.name, self.wa_account_id.id) uploads_session_response = self.__api_requests("POST", f"/{app_uid}/uploads", params=params) uploads_session_response_json = uploads_session_response.json() upload_session_id = uploads_session_response_json.get('id') if not upload_session_id: raise WhatsAppError(_("Document upload session open failed, please retry after sometime.")) # Upload file _logger.info("Upload sample document on the opened session using account %s [%s]", self.wa_account_id.name, self.wa_account_id.id) upload_file_response = self.__api_requests("POST", f"/{upload_session_id}", params=params, auth_type="oauth", headers={'file_offset': '0'}, data=attachment.raw) upload_file_response_json = upload_file_response.json() file_handle = upload_file_response_json.get('h') if not file_handle: raise WhatsAppError(_("Document upload failed, please retry after sometime.")) return file_handle def _submit_template_new(self, json_data): """ This method is used to submit template for approval If template was submitted before, we have wa_template_uid and we call template update URL API Documentation: https://developers.facebook.com/docs/graph-api/reference/whats-app-business-account/message_templates#Creating """ if self.is_shared_account: raise WhatsAppError(failure_type='account') _logger.info("Submit new template for account %s [%s]", self.wa_account_id.name, self.wa_account_id.id) response = self.__api_requests("POST", f"/{self.wa_account_id.account_uid}/message_templates", auth_type="bearer", headers={'Content-Type': 'application/json'}, data=json_data) response_json = response.json() if response_json.get('id'): return {'id': response_json['id'], 'status': response_json['status']} raise WhatsAppError(*self._prepare_error_response(response_json)) def _submit_template_update(self, json_data, wa_template_uid): if self.is_shared_account: raise WhatsAppError(failure_type='account') _logger.info("Update template : %s for account %s [%s]", wa_template_uid, self.wa_account_id.name, self.wa_account_id.id) response = self.__api_requests("POST", f"/{wa_template_uid}", auth_type="bearer", headers={'Content-Type': 'application/json'}, data=json_data) response_json = response.json() if response_json.get('success'): return True raise WhatsAppError(*self._prepare_error_response(response_json)) def _send_whatsapp(self, number, message_type, send_vals, parent_message_id=False): """ Send WA messages for all message type using WhatsApp Business Account API Documentation: Normal - https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages Template send - https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates """ data = { 'messaging_product': 'whatsapp', 'recipient_type': 'individual', 'to': number } # if there is parent_message_id then we send message as reply if parent_message_id: data.update({ 'context': { 'message_id': parent_message_id }, }) if message_type in ('template', 'text', 'document', 'image', 'audio', 'video'): data.update({ 'type': message_type, message_type: send_vals }) json_data = json.dumps(data) _logger.info("Send %s message from account %s [%s]", message_type, self.wa_account_id.name, self.wa_account_id.id) response = self.__api_requests( "POST", f"/{self.phone_uid}/messages", auth_type="bearer", headers={'Content-Type': 'application/json'}, data=json_data ) response_json = response.json() if response_json.get('messages'): msg_uid = response_json['messages'][0]['id'] return msg_uid raise WhatsAppError(*self._prepare_error_response(response_json)) def _get_header_data_from_handle(self, url): """ This method is used to get template demo document from url """ _logger.info("Get header data for url %s from account %s [%s]", url, self.wa_account_id.name, self.wa_account_id.id) response = self.__api_requests("GET", url, endpoint_include=True) mimetype = requests.head(url, timeout=5).headers.get('Content-Type') data = response.content return data, mimetype def _get_whatsapp_document(self, document_id): """ This method is used to get document from WhatsApp sent by user API Documentation: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media """ _logger.info("Get document url for document uid %s from account %s [%s]", document_id, self.wa_account_id.name, self.wa_account_id.id) response = self.__api_requests("GET", f"/{document_id}", auth_type="bearer") response_json = response.json() file_url = response_json.get('url') _logger.info("Get document from url for account %s [%s]", self.wa_account_id.name, self.wa_account_id.id) file_response = self.__api_requests("GET", file_url, auth_type="bearer", endpoint_include=True) return file_response.content def _upload_whatsapp_document(self, attachment): """ This method is used to upload document for sending via WhatsApp API Documentation: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media """ payload = {'messaging_product': 'whatsapp'} files = [('file', (attachment.name, attachment.raw, attachment.mimetype))] _logger.info("Upload document of mimetype %s for phone uid %s", attachment.mimetype, self.phone_uid) response = self.__api_requests("POST", f"/{self.phone_uid}/media", auth_type='bearer', data=payload, files=files) response_json = response.json() if response_json.get('id'): return response_json['id'] raise WhatsAppError(*self._prepare_error_response(response_json)) def _test_connection(self): """ This method is used to test connection of WhatsApp Business Account""" _logger.info("Test connection: Verify set phone uid is available in account %s [%s]", self.wa_account_id.name, self.wa_account_id.id) response = self.__api_requests("GET", f"/{self.wa_account_id.account_uid}/phone_numbers", auth_type='bearer') data = response.json().get('data', []) phone_values = [phone['id'] for phone in data if 'id' in phone] if self.wa_account_id.phone_uid not in phone_values: raise WhatsAppError(_("Phone number Id is wrong."), 'account') _logger.info("Test connection: check app uid and token set in account %s [%s]", self.wa_account_id.name, self.wa_account_id.id) uploads_session_response = self.__api_requests("POST", f"/{self.wa_account_id.app_uid}/uploads", params={'access_token': self.token}) upload_session_id = uploads_session_response.json().get('id') if not upload_session_id: raise WhatsAppError(*self._prepare_error_response(uploads_session_response.json())) return