# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import logging
import markupsafe
from markupsafe import Markup, escape
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.whatsapp.tools import phone_validation as wa_phone_validation
from odoo.addons.whatsapp.tools.retryable_codes import WHATSAPP_RETRYABLE_ERROR_CODES
from odoo.addons.whatsapp.tools.bounced_codes import BOUNCED_ERROR_CODES
from odoo.addons.whatsapp.tools.whatsapp_api import WhatsAppApi
from odoo.addons.whatsapp.tools.whatsapp_exception import WhatsAppError
from odoo.exceptions import ValidationError, UserError
from odoo.tools import groupby, html2plaintext
_logger = logging.getLogger(__name__)
class WhatsAppMessage(models.Model):
_name = 'whatsapp.message'
_description = 'WhatsApp Messages'
_order = 'id desc'
_rec_name = 'mobile_number'
# Refer to https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media/#supported-media-types
# for more details about supported media types
_SUPPORTED_ATTACHMENT_TYPE = {
'audio': ('audio/aac', 'audio/mp4', 'audio/mpeg', 'audio/amr', 'audio/ogg'),
'document': (
'text/plain', 'application/pdf', 'application/vnd.ms-powerpoint', 'application/msword',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
),
'image': ('image/jpeg', 'image/png'),
'video': ('video/mp4',),
}
# amount of days during which a message is considered active
# used for GC and for finding an active document channel using a recent whatsapp template message
_ACTIVE_THRESHOLD_DAYS = 15
mobile_number = fields.Char(string="Sent To")
mobile_number_formatted = fields.Char(
string="Mobile Number Formatted",
compute="_compute_mobile_number_formatted", readonly=False, store=True)
message_type = fields.Selection([
('outbound', 'Outbound'),
('inbound', 'Inbound')], string="Message Type", default='outbound')
state = fields.Selection(selection=[
('outgoing', 'In Queue'),
('sent', 'Sent'),
('delivered', 'Delivered'),
('read', 'Read'),
('replied', 'Replied'), # used internally
('received', 'Received'),
('error', 'Failed'),
('bounced', 'Bounced'), # failure linked to number usage, different from pure whatsapp error
('cancel', 'Cancelled')], string="State", default='outgoing')
failure_type = fields.Selection([
('account', 'Account Error'),
('blacklisted', 'Blacklisted Phone Number'),
('network', 'Network Error'),
('phone_invalid', 'Wrong Number Format'),
('template', 'Template Quality Rating Too Low'),
('unknown', 'Unknown Error'),
('whatsapp_recoverable', 'Identified Error'),
('whatsapp_unrecoverable', 'Other Technical Error')
])
failure_reason = fields.Char(string="Failure Reason", help="Usually an error message from Whatsapp")
free_text_json = fields.Json(string="Free Text Template Parameters")
wa_template_id = fields.Many2one(comodel_name='whatsapp.template')
msg_uid = fields.Char(string="WhatsApp Message ID")
wa_account_id = fields.Many2one(comodel_name='whatsapp.account', string="WhatsApp Business Account")
parent_id = fields.Many2one('whatsapp.message', 'Response To', ondelete="set null")
mail_message_id = fields.Many2one(comodel_name='mail.message', index=True)
body = fields.Html(related='mail_message_id.body', string="Body", related_sudo=False)
_sql_constraints = [
('unique_msg_uid', 'unique(msg_uid)', "Each whatsapp message should correspond to a single message uuid.")
]
@api.depends('mobile_number')
def _compute_mobile_number_formatted(self):
for message in self:
recipient_partner = message.mail_message_id.partner_ids[0] if message.mail_message_id.partner_ids else self.env['res.partner']
country = recipient_partner.country_id if recipient_partner.country_id else self.env.company.country_id
formatted = wa_phone_validation.wa_phone_format(
country, # could take mail.message record as context but seems overkill
number=message.mobile_number,
country=country,
force_format="WHATSAPP",
raise_exception=False,
)
message.mobile_number_formatted = formatted or ''
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
@api.model_create_multi
def create(self, vals):
"""Override to check blacklist number and also add to blacklist if user has send stop message."""
messages = super().create(vals)
for message in messages:
body = html2plaintext(message.body)
if message.message_type == 'inbound' and message.mobile_number_formatted:
body_message = re.findall('([a-zA-Z]+)', body)
message_string = "".join(i.lower() for i in body_message)
try:
if message_string in self._get_opt_out_message():
self.env['phone.blacklist'].sudo().add(
number=f'+{message.mobile_number_formatted}', # from WA to E164 format
message=_("User has been opt out of receiving WhatsApp messages"),
)
else:
self.env['phone.blacklist'].sudo().remove(
number=f'+{message.mobile_number_formatted}', # from WA to E164 format
message=_("User has opted in to receiving WhatsApp messages"),
)
except UserError:
# there was something wrong with number formatting that cannot be
# accepted by the blacklist -> simply skip, better be defensive
_logger.warning(
'Whatsapp: impossible to change opt-in status of %s (formatted as %s) as it is not a valid number (whatsapp.message-%s)',
message.mobile_number, message.mobile_number_formatted, message.id
)
return messages
@api.autovacuum
def _gc_whatsapp_messages(self):
""" To avoid bloating the database, we remove old whatsapp.messages that have been correctly
received / sent and are older than 15 days.'
We use these messages mainly to tie a customer answer to a certain document channel, but
only do so for the last 15 days (see '_find_active_channel').
After that period, they become non-relevant as the real content of conversations is kept
inside discuss.channel / mail.messages (as every other discussions).
Impact of GC when using the 'reply-to' function from the WhatsApp app as the customer:
- We could loose the context that a message is 'a reply to' another one, implying that
someone would reply to a message after 15 days, which is unlikely.
(To clarify: we will still receive the message, it will just not give the 'in-reply-to'
context anymore on the discuss channel).
- We could also loose the "right channel" in that case, and send the message to a another
(or a new) discuss channel, but it is again unlikely to answer more than 15 days later. """
domain = self._get_whatsapp_gc_domain()
self.env['whatsapp.message'].search(domain).unlink()
def _get_whatsapp_gc_domain(self):
date_threshold = fields.Datetime.now() - timedelta(
days=self.env['whatsapp.message']._ACTIVE_THRESHOLD_DAYS)
return [
('create_date', '<', date_threshold),
('state', 'not in', ['outgoing', 'error', 'cancel'])
]
def _get_formatted_number(self, sanitized_number, country_code):
""" Format a valid mobile number for whatsapp.
:examples:
'+919999912345' -> '919999912345'
:return: formatted mobile number
TDE FIXME: remove in master
"""
mobile_number_parse = phone_validation.phone_parse(sanitized_number, country_code)
return f'{mobile_number_parse.country_code}{mobile_number_parse.national_number}'
@api.model
def _get_opt_out_message(self):
return ['stop', 'unsubscribe', 'stop promotions']
# ------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------
def button_resend(self):
""" Resend a failed message. """
if self.filtered(lambda rec: rec.state != 'error'):
raise UserError(_("You can not resend message which is not in failed state."))
self._resend_failed()
def button_cancel_send(self):
""" Cancel a draft or outgoing message. """
if self.filtered(lambda rec: rec.state != 'outgoing'):
raise UserError(_("You can not cancel message which is in queue."))
self.state = 'cancel'
# ------------------------------------------------------------
# SEND
# ------------------------------------------------------------
def _resend_failed(self):
""" Resend failed messages. """
retryable_messages = self.filtered(lambda msg: msg.state == 'error' and msg.failure_type != 'whatsapp_unrecoverable')
retryable_messages.write({'state': 'outgoing', 'failure_type': False, 'failure_reason': False})
self.env.ref('whatsapp.ir_cron_send_whatsapp_queue')._trigger()
def _send_cron(self):
""" Send all outgoing messages. """
records = self.search([
('state', '=', 'outgoing'), ('wa_template_id', '!=', False)
], limit=500)
records._send_message(with_commit=True)
if len(records) == 500: # assumes there are more whenever search hits limit
self.env.ref('whatsapp.ir_cron_send_whatsapp_queue')._trigger()
def _send(self, force_send_by_cron=False):
if len(self) <= 1 and not force_send_by_cron:
self._send_message()
else:
self.env.ref('whatsapp.ir_cron_send_whatsapp_queue')._trigger()
def _send_message(self, with_commit=False):
""" Prepare json data for sending messages, attachments and templates."""
# init api
message_to_api = {}
for account, messages in groupby(self, lambda msg: msg.wa_account_id):
if not account:
messages = self.env['whatsapp.message'].concat(*messages)
messages.write({
'failure_type': 'unknown',
'failure_reason': 'Missing whatsapp account for message.',
'state': 'error',
})
self -= messages
continue
wa_api = WhatsAppApi(account)
for message in messages:
message_to_api[message] = wa_api
for whatsapp_message in self:
wa_api = message_to_api[whatsapp_message]
# try to make changes with current user (notably due to ACLs), but limit
# to internal users to avoid crash - rewrite me in master please
if whatsapp_message.create_uid._is_internal():
whatsapp_message = whatsapp_message.with_user(whatsapp_message.create_uid)
if whatsapp_message.state != 'outgoing':
_logger.info("Message state in %s state so it will not sent.", whatsapp_message.state)
continue
msg_uid = False
try:
parent_message_id = False
body = whatsapp_message.body
if isinstance(body, markupsafe.Markup):
# If Body is in html format so we need to remove html tags before sending message.
body = body.striptags()
number = whatsapp_message.mobile_number_formatted
if not number:
raise WhatsAppError(failure_type='phone_invalid')
if self.env['phone.blacklist'].sudo().search_count([('number', 'ilike', number), ('active', '=', True)], limit=1):
raise WhatsAppError(failure_type='blacklisted')
# based on template
if whatsapp_message.wa_template_id:
message_type = 'template'
if whatsapp_message.wa_template_id.status != 'approved' or whatsapp_message.wa_template_id.quality == 'red':
raise WhatsAppError(failure_type='template')
whatsapp_message.message_type = 'outbound'
if whatsapp_message.mail_message_id.model != whatsapp_message.wa_template_id.model:
raise WhatsAppError(failure_type='template')
RecordModel = self.env[whatsapp_message.mail_message_id.model].with_user(whatsapp_message.create_uid)
from_record = RecordModel.browse(whatsapp_message.mail_message_id.res_id)
# if retrying message then we need to unlink previous attachment
# in case of header with report in order to generate it again
if whatsapp_message.wa_template_id.report_id and whatsapp_message.wa_template_id.header_type == 'document' and whatsapp_message.mail_message_id.attachment_ids:
whatsapp_message.mail_message_id.attachment_ids.unlink()
# generate sending values, components and attachments
send_vals, attachment = whatsapp_message.wa_template_id._get_send_template_vals(
record=from_record,
whatsapp_message=whatsapp_message,
)
if attachment and attachment not in whatsapp_message.mail_message_id.attachment_ids:
whatsapp_message.mail_message_id.attachment_ids = [(4, attachment.id)]
# no template
elif whatsapp_message.mail_message_id.attachment_ids:
attachment_vals = whatsapp_message._prepare_attachment_vals(whatsapp_message.mail_message_id.attachment_ids[0], wa_account_id=whatsapp_message.wa_account_id)
message_type = attachment_vals.get('type')
send_vals = attachment_vals.get(message_type)
if whatsapp_message.body:
send_vals['caption'] = body
else:
message_type = 'text'
send_vals = {
'preview_url': True,
'body': body,
}
# Tagging parent message id if parent message is available
if whatsapp_message.mail_message_id and whatsapp_message.mail_message_id.parent_id:
parent_id = whatsapp_message.mail_message_id.parent_id.wa_message_ids
if parent_id:
parent_message_id = parent_id[0].msg_uid
msg_uid = wa_api._send_whatsapp(number=number, message_type=message_type, send_vals=send_vals, parent_message_id=parent_message_id)
except WhatsAppError as we:
whatsapp_message._handle_error(whatsapp_error_code=we.error_code, error_message=we.error_message,
failure_type=we.failure_type)
except (UserError, ValidationError) as e:
whatsapp_message._handle_error(failure_type='unknown', error_message=str(e))
else:
if not msg_uid:
whatsapp_message._handle_error(failure_type='unknown')
else:
if message_type == 'template':
whatsapp_message._post_message_in_active_channel()
whatsapp_message.write({
'state': 'sent',
'msg_uid': msg_uid
})
if with_commit:
self._cr.commit()
def _handle_error(self, failure_type=False, whatsapp_error_code=False, error_message=False):
""" Format and write errors on the message. """
self.ensure_one()
state = 'error'
if whatsapp_error_code:
if whatsapp_error_code in WHATSAPP_RETRYABLE_ERROR_CODES:
failure_type = 'whatsapp_recoverable'
else:
failure_type = 'whatsapp_unrecoverable'
if not failure_type:
failure_type = 'unknown'
if whatsapp_error_code in BOUNCED_ERROR_CODES:
state = 'bounced'
self.write({
'failure_type': failure_type,
'failure_reason': error_message,
'state': state,
})
def _post_message_in_active_channel(self):
""" Notify the active channel that someone has sent template message. """
self.ensure_one()
if not self.wa_template_id:
return
channel = self.wa_account_id._find_active_channel(self.mobile_number_formatted)
if not channel:
return
# same user, different model: print full content of message into conversation
if channel.is_member:
body = self.body
message_type = "comment"
# diffrent user from any model: warn chat is about to be moved into a new conversation
else:
record_name = self.mail_message_id.record_name
if self.mail_message_id.model and self.mail_message_id.res_id:
if not record_name:
record_name = self.env[self.mail_message_id.model].browse(self.mail_message_id.res_id).display_name
url = f"{self.get_base_url()}/odoo/{self.mail_message_id.model}/{self.mail_message_id.res_id}"
record_link = f"{escape(record_name)}"
else:
record_link = record_name or _("another document")
body = Markup(
_("A new template was sent on %(record_link)s.
"
"Future replies will be transferred to a new chat.",
record_link=record_link
))
message_type = "notification"
channel.sudo().message_post(
body=body,
message_type=message_type,
subtype_xmlid='mail.mt_comment',
)
@api.model
def _prepare_attachment_vals(self, attachment, wa_account_id):
""" Upload the attachment to WhatsApp and return prepared values to attach to the message. """
whatsapp_media_type = next((
media_type
for media_type, mimetypes
in self._SUPPORTED_ATTACHMENT_TYPE.items()
if attachment.mimetype in mimetypes),
False
)
if not whatsapp_media_type:
raise WhatsAppError(_("Attachment mimetype is not supported by WhatsApp: %s.", attachment.mimetype))
wa_api = WhatsAppApi(wa_account_id)
whatsapp_media_uid = wa_api._upload_whatsapp_document(attachment)
vals = {
'type': whatsapp_media_type,
whatsapp_media_type: {'id': whatsapp_media_uid}
}
if whatsapp_media_type == 'document':
vals[whatsapp_media_type]['filename'] = attachment.name
return vals
# ------------------------------------------------------------
# CALLBACK
# ------------------------------------------------------------
def _process_statuses(self, value):
""" Process status of the message like 'send', 'delivered' and 'read'."""
mapping = {'failed': 'error', 'cancelled': 'cancel'}
processed_message_ids = set()
for statuses in value.get('statuses', []):
whatsapp_message = self.env['whatsapp.message'].sudo().search([('msg_uid', '=', statuses['id'])])
if whatsapp_message:
whatsapp_message.state = mapping.get(statuses['status'], statuses['status'])
processed_message_ids.add(whatsapp_message.id)
whatsapp_message._update_message_fetched_seen()
if statuses['status'] == 'failed':
error = statuses['errors'][0] if statuses.get('errors') else None
if error:
whatsapp_message._handle_error(whatsapp_error_code=error['code'],
error_message=f"{error['code']} : {error['title']}")
return self.env['whatsapp.message'].browse(sorted(processed_message_ids, reverse=True)).sudo()
def _update_message_fetched_seen(self):
""" Update message status for the whatsapp recipient. """
self.ensure_one()
if self.mail_message_id.model != 'discuss.channel':
return
channel = self.env['discuss.channel'].browse(self.mail_message_id.res_id)
channel_member = channel.channel_member_ids.filtered(lambda cm: cm.partner_id == channel.whatsapp_partner_id)
if not channel_member:
return
channel_member = channel_member[0]
notification_type = None
if self.state == 'read':
channel_member.write({
'fetched_message_id': max(channel_member.fetched_message_id.id, self.mail_message_id.id),
'seen_message_id': self.mail_message_id.id,
'last_seen_dt': fields.Datetime.now(),
})
notification_type = 'discuss.channel.member/seen'
elif self.state == 'delivered':
channel_member.write({'fetched_message_id': self.mail_message_id.id})
notification_type = 'discuss.channel.member/fetched'
if notification_type:
channel._bus_send(
notification_type,
{
"channel_id": channel.id,
"id": channel_member.id,
"last_message_id": self.mail_message_id.id,
"partner_id": channel.whatsapp_partner_id.id,
},
)