454 lines
22 KiB
Python
454 lines
22 KiB
Python
# 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"<a target='_blank' href='{url}'>{escape(record_name)}</a>"
|
|
else:
|
|
record_link = record_name or _("another document")
|
|
body = Markup(
|
|
_("A new template was sent on %(record_link)s.<br>"
|
|
"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,
|
|
},
|
|
)
|