Files
enterprise-addons/whatsapp/models/whatsapp_account.py
T

351 lines
17 KiB
Python

# 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('<a target="_blank" href="{url}"> <i class="fa fa-map-marker"/> {location_string} </a>').format(
url=url, location_string=_("Location"))
if messages['location'].get('name'):
body += Markup("<br/>{location_name}").format(location_name=messages['location']['name'])
if messages['location'].get('address'):
body += Markup("<br/>{location_address}").format(location_address=messages['location']['address'])
kwargs['body'] = body
elif message_type == 'contacts':
body = ""
for contact in messages['contacts']:
body += Markup("<i class='fa fa-address-book'/> {contact_name} <br/>").format(
contact_name=contact.get('name', {}).get('formatted_name', ''))
for phone in contact.get('phones'):
body += Markup("{phone_type}: {phone_number}<br/>").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