# Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import mimetypes import psycopg2 import re import secrets import string from markupsafe import Markup from odoo import SUPERUSER_ID, api, fields, models, _ from odoo.exceptions import UserError, ValidationError from odoo.addons.whatsapp.tools.whatsapp_api import WhatsAppApi from odoo.addons.whatsapp.tools.whatsapp_exception import WhatsAppError from odoo.modules.registry import Registry from odoo.tools import plaintext2html from odoo.addons.whatsapp.tools import phone_validation as wa_phone_validation _logger = logging.getLogger(__name__) class WhatsappAccount(models.Model): _name = 'whatsapp.account' _inherit = ['mail.thread'] _description = 'WhatsApp Business Account' name = fields.Char(string="Name", tracking=1) active = fields.Boolean(default=True, tracking=6) app_uid = fields.Char(string="App ID", required=True, tracking=2) app_secret = fields.Char(string="App Secret", groups='whatsapp.group_whatsapp_admin', required=True) account_uid = fields.Char(string="Account ID", required=True, tracking=3) phone_uid = fields.Char(string="Phone Number ID", required=True, tracking=4) phone_number = fields.Char(string="Phone Number", copy=False, readonly=True, help="The phone number used in the Whatsapp Business Account.") token = fields.Char(string="Access Token", required=True, groups='whatsapp.group_whatsapp_admin') webhook_verify_token = fields.Char(string="Webhook Verify Token", compute='_compute_verify_token', groups='whatsapp.group_whatsapp_admin', store=True) callback_url = fields.Char(string="Callback URL", compute='_compute_callback_url', readonly=True, copy=False) debug_logging = fields.Boolean(string="Debug logging", help="Log requests in order to ease debugging") allowed_company_ids = fields.Many2many( comodel_name='res.company', string="Allowed Company", default=lambda self: self.env.company) notify_user_ids = fields.Many2many( comodel_name='res.users', default=lambda self: self.env.user, domain=[('share', '=', False)], required=True, tracking=5, help="Users to notify when a message is received and there is no template send in last 15 days") templates_count = fields.Integer(string="Message Count", compute='_compute_templates_count') _phone_uid_unique = models.Constraint( 'unique(phone_uid)', "The same phone number ID already exists", ) @api.constrains('notify_user_ids') def _check_notify_user_ids(self): for phone in self: if len(phone.notify_user_ids) < 1: raise ValidationError(_("Users to notify is required")) def _compute_callback_url(self): for account in self: account.callback_url = self.get_base_url() + '/whatsapp/webhook' @api.depends('account_uid') def _compute_verify_token(self): """ webhook_verify_token only set when record is created. Not update after that.""" for rec in self: if rec.id and not rec.webhook_verify_token: rec.webhook_verify_token = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8)) def _compute_templates_count(self): for tmpl in self: tmpl.templates_count = self.env['whatsapp.template'].search_count([('wa_account_id', 'in', tmpl.ids)]) def button_sync_whatsapp_account_templates(self): """ This method will sync all the templates of the WhatsApp Business Account. It will create new templates and update existing templates. """ self.ensure_one() try: response = WhatsAppApi(self)._get_all_template(fetch_all=True) wa_phone_number = WhatsAppApi(self)._get_phone_number(self.phone_uid) if wa_phone_number: self.phone_number = wa_phone_number except WhatsAppError as err: raise ValidationError(str(err)) from err WhatsappTemplate = self.env['whatsapp.template'] existing_tmpls = WhatsappTemplate.with_context(active_test=False).search([('wa_account_id', '=', self.id)]) existing_tmpl_by_id = {t.wa_template_uid: t for t in existing_tmpls} template_update_count = 0 template_create_count = 0 if response.get('data'): create_vals = [] for template in response['data']: existing_tmpl = existing_tmpl_by_id.get(template['id']) if existing_tmpl: template_update_count += 1 existing_tmpl._update_template_from_response(template) else: template_create_count += 1 create_vals.append(WhatsappTemplate._create_template_from_response(template, self)) WhatsappTemplate.create(create_vals) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _("Templates synchronized!"), 'type': 'success', 'message': _("%(create_count)s were created, %(update_count)s were updated", create_count=template_create_count, update_count=template_update_count), 'next': {'type': 'ir.actions.act_window_close'}, } } def button_test_connection(self): """ Test connection of the WhatsApp Business Account. with the given credentials. """ self.ensure_one() wa_api = WhatsAppApi(self) try: wa_api._test_connection() wa_phone_number = wa_api._get_phone_number(self.phone_uid) if wa_phone_number: self.phone_number = wa_phone_number except WhatsAppError as e: raise UserError(str(e)) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'type': 'success', 'message': _("Credentials look good!"), 'next': {'type': 'ir.actions.act_window_close'}, } } def action_debug(self): """Enable debug logging.""" self.ensure_one() previous_debug_state = self.debug_logging self.debug_logging = True return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Warning'), 'type': 'warning', 'message': _('Enabling WhatsApp debug stops medias (pictures, videos, pdf, ...) from being received.'), }, } if not previous_debug_state else None def action_stop_debug(self): """Disable debug logging.""" self.ensure_one() self.debug_logging = False def action_open_templates(self): self.ensure_one() return { 'name': _("Templates Of %(account_name)s", account_name=self.name), 'view_mode': 'list,form', 'res_model': 'whatsapp.template', 'domain': [('wa_account_id', '=', self.id)], 'type': 'ir.actions.act_window', 'context': {'default_wa_account_id': self.id}, } def _add_ir_log(self, name, message, func=''): self.ensure_one() self.env.flush_all() db_name = self.env.cr.dbname # Use a new cursor to avoid rollback that could be caused by an upper method try: db_registry = Registry(db_name) with db_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) env['ir.logging'].create({ 'dbname': db_name, 'func': func, 'level': 'DEBUG', 'line': '', 'message': message, 'name': name, 'path': f'whatsapp.account,id={self.id},name={self.name}', 'type': 'server', }) except psycopg2.Error: pass def _find_active_channel_from_whatsapp_message_values(self, whatsapp_message_values, whatsapp_contacts_values, create_if_not_found=False): self.ensure_one() # FIXME? should probably try to match messages to contacts instead of assuming we'll only ever get one sender_name = (whatsapp_contacts_values or [{}])[0].get('profile', {}).get('name') sender_mobile_formatted = None from_number = whatsapp_message_values.get('from') if from_number and (sender_mobile_formatted := self._format_incoming_from_number(from_number)): return self._find_active_channel(sender_mobile_formatted, sender_name=sender_name, create_if_not_found=create_if_not_found) return self.env['discuss.channel'] def _find_active_channel(self, sender_mobile_formatted, sender_name=False, create_if_not_found=False): """This method will find the active channel for the given sender mobile number.""" self.ensure_one() return self._find_active_channel_from_identifiers( {'number': sender_mobile_formatted}, sender_name=sender_name, create_if_not_found=create_if_not_found, ) def _get_message_identifiers_domain(self, identifiers): if identifiers.get('number'): return fields.Domain('mobile_number_formatted', '=', identifiers['number']) return fields.Domain.FALSE def _find_active_channel_from_identifiers(self, identifiers, sender_name=False, create_if_not_found=False): """Find the active channel from whatsapp 'identifiers'. :param identifiers: a dict of shape {'number': str, 'wa_id': str, 'bsuid': str}, or a subdict of it. In the base module only 'number' will be passed around. :param sender_name: an optional user-readable name for the sender if creating a channel and/or partner. If not set will default to a field value, such as the phone number, or bsuid. :param create_if_not_found: whether to create a channel, and implicitly whether to create a partner to be linked to that channel and represent the sender. """ self.ensure_one() domain = self._get_message_identifiers_domain(identifiers) + [ ('wa_account_id', '=', self.id), ('wa_template_id', '!=', False), ('state', 'not in', ['outgoing', 'error', 'cancel']), ] whatsapp_message = self.env['whatsapp.message'].sudo().search(domain, limit=1, order='id desc') channel = self.env['discuss.channel'].sudo()._get_whatsapp_channel_from_identifiers( wa_account_id=self, identifiers=identifiers, sender_name=sender_name, create_if_not_found=create_if_not_found, related_message=whatsapp_message.mail_message_id, ) # sync any newly-learned identifiers onto the partner if channel_partner := channel.whatsapp_partner_id: channel_partner._update_from_whatsapp_identifiers(identifiers, self) return channel def _process_messages(self, value): """ This method is used for processing messages with the values received via webhook. If any whatsapp message template has been sent from this account then it will find the active channel or create new channel with last template message sent to that number and post message in that channel. And if channel is not found then it will create new channel with notify user set in account and post message. Supported Messages => Text Message => Attachment Message with caption => Location Message => Contact Message => Message Reactions """ if 'messages' not in value and value.get('whatsapp_business_api_data', {}).get('messages'): value = value['whatsapp_business_api_data'] wa_api = WhatsAppApi(self) for messages in value.get('messages', []): parent_msg_id = False parent_id = False channel = False message_type = messages['type'] if 'context' in messages and messages['context'].get('id'): parent_whatsapp_message = self.env['whatsapp.message'].sudo().search([('msg_uid', '=', messages['context']['id'])]) if parent_whatsapp_message: parent_msg_id = parent_whatsapp_message.id parent_id = parent_whatsapp_message.mail_message_id if parent_id: channel = self.env['discuss.channel'].sudo().search([('message_ids', 'in', parent_id.id)], limit=1) if not channel: channel = self._find_active_channel_from_whatsapp_message_values(messages, value.get('contacts'), create_if_not_found=True) kwargs = { 'message_type': 'whatsapp_message', 'author_id': channel.whatsapp_partner_id.id, 'parent_msg_id': parent_msg_id, 'subtype_xmlid': 'mail.mt_comment', 'parent_id': parent_id.id if parent_id else None, } if message_type == 'text': kwargs['body'] = plaintext2html(messages['text']['body']) elif message_type == 'button': kwargs['body'] = messages['button']['text'] elif message_type in ('document', 'image', 'audio', 'video', 'sticker'): filename = messages[message_type].get('filename') is_voice = messages[message_type].get('voice') mime_type = messages[message_type].get('mime_type') caption = messages[message_type].get('caption') datas = wa_api._get_whatsapp_document(messages[message_type]['id']) if not filename: extension = mimetypes.guess_extension(mime_type) or '' filename = message_type + extension kwargs['attachments'] = [(filename, datas, {'voice': is_voice})] if caption: kwargs['body'] = plaintext2html(caption) elif message_type == 'location': url = Markup("https://maps.google.com/maps?q={latitude},{longitude}").format( latitude=messages['location']['latitude'], longitude=messages['location']['longitude']) body = Markup(' {location_string} ').format( url=url, location_string=_("Location")) if messages['location'].get('name'): body += Markup("
{location_name}").format(location_name=messages['location']['name']) if messages['location'].get('address'): body += Markup("
{location_address}").format(location_address=messages['location']['address']) kwargs['body'] = body elif message_type == 'contacts': body = "" for contact in messages['contacts']: body += Markup(" {contact_name}
").format( contact_name=contact.get('name', {}).get('formatted_name', '')) for phone in contact.get('phones'): body += Markup("{phone_type}: {phone_number}
").format( phone_type=phone.get('type'), phone_number=phone.get('phone')) kwargs['body'] = body elif message_type == 'reaction': msg_uid = messages['reaction'].get('message_id') whatsapp_message = self.env['whatsapp.message'].sudo().search([('msg_uid', '=', msg_uid)]) if whatsapp_message: partner_id = channel.whatsapp_partner_id emoji = messages['reaction'].get('emoji') whatsapp_message.mail_message_id._post_whatsapp_reaction(reaction_content=emoji, partner_id=partner_id) continue else: _logger.warning("Unsupported whatsapp message type: %s", messages) continue channel.message_post(whatsapp_inbound_msg_uid=messages['id'], **kwargs) # UTILS @api.model def _format_incoming_from_number(self, from_number): if not from_number: return from_number cleaned_number = re.sub(r"[^\d+]", "", from_number) cleaned_number = "+" + re.sub(r"\D", "", cleaned_number) return wa_phone_validation.wa_phone_format( self, country=None, number=cleaned_number, force_format="WHATSAPP", raise_exception=False, ) or cleaned_number