Whatsapp new module

This commit is contained in:
Pranay 2025-03-11 13:55:28 +05:30
parent 52e90b5d04
commit a7506c2299
162 changed files with 90173 additions and 0 deletions

View File

@ -0,0 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controller
from . import models
from . import tools
from . import wizard
from . import populate

View File

@ -0,0 +1,60 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'WhatsApp Messaging',
'category': 'Marketing/WhatsApp',
'summary': 'Text your Contacts on WhatsApp',
'version': '1.0',
'description': """This module integrates Odoo with WhatsApp to use WhatsApp messaging service""",
'depends': ['mail', 'phone_validation'],
'data': [
'data/ir_actions_server_data.xml',
'data/ir_cron_data.xml',
'data/ir_module_category_data.xml',
'data/whatsapp_templates_preview.xml',
'security/res_groups.xml',
'security/ir_rules.xml',
'security/ir.model.access.csv',
'wizard/whatsapp_preview_views.xml',
'wizard/whatsapp_composer_views.xml',
'views/discuss_channel_views.xml',
'views/ir_actions_server_views.xml',
'views/whatsapp_account_views.xml',
'views/whatsapp_message_views.xml',
'views/whatsapp_template_views.xml',
'views/whatsapp_template_button_views.xml',
'views/whatsapp_template_variable_views.xml',
'views/res_config_settings_views.xml',
'views/whatsapp_menus.xml',
'views/res_partner_views.xml',
],
'demo': [
'data/whatsapp_demo.xml',
],
'external_dependencies': {
'python': ['phonenumbers'],
},
'assets': {
'web.assets_backend': [
'whatsapp/static/src/scss/*.scss',
'whatsapp/static/src/core/common/**/*',
'whatsapp/static/src/core/web/**/*',
'whatsapp/static/src/core/public_web/**/*',
'whatsapp/static/src/**/common/**/*',
'whatsapp/static/src/**/web/**/*',
'whatsapp/static/src/components/**/*',
'whatsapp/static/src/views/**/*',
# Don't include dark mode files in light mode
('remove', 'whatsapp/static/src/**/*.dark.scss'),
],
"web.assets_web_dark": [
'whatsapp/static/src/**/*.dark.scss',
],
'web.assets_unit_tests': [
# 'whatsapp/static/tests/**/*',
],
},
'license': 'OEEL-1',
'application': True,
'installable': True,
}

View File

@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main

View File

@ -0,0 +1,110 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import hmac
import json
import logging
from markupsafe import Markup
from werkzeug.exceptions import Forbidden
from http import HTTPStatus
from odoo import http, _
from odoo.http import request
from odoo.tools import consteq
_logger = logging.getLogger(__name__)
class Webhook(http.Controller):
@http.route('/whatsapp/webhook/', methods=['POST'], type="json", auth="public")
def webhookpost(self):
data = json.loads(request.httprequest.data)
for entry in data['entry']:
account_id = entry['id']
account = request.env['whatsapp.account'].sudo().search(
[('account_uid', '=', account_id)])
if not self._check_signature(account):
raise Forbidden()
for changes in entry.get('changes', []):
value = changes['value']
phone_number_id = value.get('metadata', {}).get('phone_number_id', {})
if not phone_number_id:
phone_number_id = value.get('whatsapp_business_api_data', {}).get('phone_number_id', {})
if phone_number_id:
wa_account_id = request.env['whatsapp.account'].sudo().search([
('phone_uid', '=', phone_number_id), ('account_uid', '=', account_id)])
if wa_account_id:
# Process Messages and Status webhooks
if changes['field'] == 'messages':
request.env['whatsapp.message']._process_statuses(value)
wa_account_id._process_messages(value)
else:
_logger.warning("There is no phone configured for this whatsapp webhook : %s ", data)
# Process Template webhooks
if value.get('message_template_id'):
# There is no user in webhook, so we need to SUPERUSER_ID to write on template object
template = request.env['whatsapp.template'].sudo().with_context(active_test=False).search([('wa_template_uid', '=', value['message_template_id'])])
if template:
if changes['field'] == 'message_template_status_update':
template.write({'status': value['event'].lower()})
if value['event'].lower() == 'rejected':
body = _("Your Template has been rejected.")
description = value.get('other_info', {}).get('description') or value.get('reason')
if description:
body += Markup("<br/>") + _("Reason : %s", description)
template.message_post(body=body)
continue
if changes['field'] == 'message_template_quality_update':
new_quality_score = value['new_quality_score'].lower()
new_quality_score = {'unknown': 'none'}.get(new_quality_score, new_quality_score)
template.write({'quality': new_quality_score})
continue
if changes['field'] == 'template_category_update':
template.write({'template_type': value['new_category'].lower()})
continue
_logger.warning("Unknown Template webhook : %s ", value)
else:
_logger.warning("No Template found for this webhook : %s ", value)
@http.route('/whatsapp/webhook/', methods=['GET'], type="http", auth="public", csrf=False)
def webhookget(self, **kwargs):
"""
This controller is used to verify the webhook.
if challenge is matched then it will make response with challenge.
once it is verified the webhook will be activated.
"""
token = kwargs.get('hub.verify_token')
mode = kwargs.get('hub.mode')
challenge = kwargs.get('hub.challenge')
if not (token and mode and challenge):
return Forbidden()
wa_account = request.env['whatsapp.account'].sudo().search([('webhook_verify_token', '=', token)])
if mode == 'subscribe' and wa_account:
response = request.make_response(challenge)
response.status_code = HTTPStatus.OK
return response
response = request.make_response({})
response.status_code = HTTPStatus.FORBIDDEN
return response
def _check_signature(self, business_account):
"""Whatsapp will sign all requests it makes to our endpoint."""
signature = request.httprequest.headers.get('X-Hub-Signature-256')
if not signature or not signature.startswith('sha256=') or len(signature) != 71:
# Signature must be valid SHA-256 (sha256=<64 hex digits>)
_logger.warning('Invalid signature header %r', signature)
return False
if not business_account.app_secret:
_logger.warning('App-secret is missing, can not check signature')
return False
expected = hmac.new(
business_account.app_secret.encode(),
msg=request.httprequest.data,
digestmod=hashlib.sha256,
).hexdigest()
return consteq(signature[7:], expected)

View File

@ -0,0 +1,12 @@
<?xml version="1.0"?>
<odoo>
<record id="ir_actions_server_resend_whatsapp_queue" model="ir.actions.server">
<field name="name">WhatsApp : Resend failed Messages</field>
<field name="model_id" ref="whatsapp.model_whatsapp_message"/>
<field name="state">code</field>
<field name="binding_view_types">list</field>
<field name="code">action = records._resend_failed()</field>
<field name="binding_model_id" eval="ref('whatsapp.model_whatsapp_message')"/>
<field name="binding_type">action</field>
</record>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="ir_cron_send_whatsapp_queue" model="ir.cron">
<field name="name">WhatsApp : Send In Queue Messages</field>
<field name="model_id" ref="whatsapp.model_whatsapp_message"/>
<field name="state">code</field>
<field name="code">model._send_cron()</field>
<field name='interval_number'>1</field>
<field name='interval_type'>hours</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="base.module_category_marketing_whatsapp" model="ir.module.category">
<field name="name">WhatsApp</field>
<field name="description">User access levels for WhatsApp module</field>
<field name="sequence">110</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,2 @@
UPDATE whatsapp_account
SET token = 'dummy_token';

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record model="discuss.channel" id="demo_whatsapp_channel">
<field name="name">Brandon Freeman (demo)</field>
<field name="channel_type">whatsapp</field>
<field name="whatsapp_number">(355)-687-3262</field>
<field name="whatsapp_partner_id" ref="base.res_partner_address_15"/>
<field name="channel_partner_ids" eval="[Command.link(ref('base.res_partner_address_15'))]"/>
<field name="group_ids" eval="[Command.link(ref('base.group_system'))]"/>
</record>
<record model="mail.message" id="welcome_mail_message">
<field name="model">discuss.channel</field>
<field name="res_id" ref="demo_whatsapp_channel"/>
<field name="message_type">whatsapp_message</field>
<field name="author_id" ref="base.res_partner_address_15"/>
<field name="body"><![CDATA[<p>This channel is for demo purpose only.</p>]]></field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="template_message_preview" t-name="WhatsApp Preview">
<div class="o_whatsapp_preview overflow-hidden ps-3 pe-5">
<div class="o_whatsapp_message mt-2 mb-1 fs-6 lh-1 float-start text-break text-black position-relative">
<div class="o_whatsapp_message_core p-2 position-relative">
<div class="o_whatsapp_message_header bg-opacity-50" t-if="header_type != 'none' and header_type != 'text'">
<div t-attf-class="d-block bg-400 p-4 text-center {{ 'rounded-top-2' if header_type == 'location' else 'rounded-2' }}">
<img class="m-2 img-fluid" t-attf-src="/whatsapp/static/img/{{header_type}}.png" t-att-alt="header_type"/>
</div>
<div t-if="header_type == 'location'" class="o_whatsapp_location_footer d-flex p-2 bg-200 rounded-bottom-2 flex-column">
<span class="o-whatsapp-font-11">{{Location name}}</span><br/>
<span class="text-600 o-whatsapp-font-9">{{Address}}</span>
</div>
</div>
<div class="o_whatsapp_message_body px-1 mt-2" t-attf-style="direction:{{language_direction}};">
<t t-out="body"/>
</div>
<div t-if="footer_text" class="o_whatsapp_message_footer px-1">
<span class="fs-6 text-400" t-out="footer_text" />
<span class="o_whatsapp_msg_space me-5 d-inline-block"/>
</div>
<span class="position-absolute bottom-0 end-0 o-whatsapp-font-11 py-1 px-2 text-black-50" area-hidden="true">
06:00
</span>
</div>
<div class="o_whatsapp_message_links cursor-default px-2" t-if="buttons">
<hr class="position-relative w-100 m-0"/>
<t t-set="filtered_buttons" t-value="buttons[:3] if len(buttons) &lt; 4 else buttons[:2]" />
<t t-set="show_all_options_button" t-value="len(buttons) &gt;= 4" />
<t t-foreach="filtered_buttons" t-as="button">
<span t-attf-class="o_whatsapp_message_link d-block text-center my-3">
<t t-if="button.button_type == 'phone_number'">
<i t-attf-class="fa fs-5 fa-phone"/>
</t>
<t t-elif="button.button_type == 'url'">
<i t-attf-class="fa fs-5 fa-external-link"/>
</t>
<t t-else="">
<i t-attf-class="fa fs-5 fa-reply"/>
</t>
<t t-out="button.name"/>
</span>
</t>
<t t-if="show_all_options_button">
<span t-attf-class="o_whatsapp_message_link d-block text-center my-3">
<i t-attf-class="fa fs-5 fa-list-ul"/> See all options
</span>
</t>
</div>
</div>
</div>
</template>
</odoo>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import discuss_channel
from . import discuss_channel_member
from . import ir_actions_server
from . import mail_message
from . import mail_thread
from . import models
from . import res_partner
from . import res_users_settings
from . import whatsapp_account
from . import whatsapp_message
from . import whatsapp_template
from . import whatsapp_template_button
from . import whatsapp_template_variable

View File

@ -0,0 +1,265 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from datetime import timedelta
from markupsafe import Markup
from odoo import api, Command, fields, models, tools, _
from odoo.addons.mail.tools.discuss import Store
from odoo.addons.whatsapp.tools import phone_validation as wa_phone_validation
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class DiscussChannel(models.Model):
""" Support WhatsApp Channels, used for discussion with a specific
whasapp number """
_inherit = 'discuss.channel'
channel_type = fields.Selection(
selection_add=[('whatsapp', 'WhatsApp Conversation')],
ondelete={'whatsapp': 'cascade'})
whatsapp_number = fields.Char(string="Phone Number")
whatsapp_channel_valid_until = fields.Datetime(string="WhatsApp Channel Valid Until Datetime", compute="_compute_whatsapp_channel_valid_until")
last_wa_mail_message_id = fields.Many2one(comodel_name="mail.message", string="Last WA Partner Mail Message", index='btree_not_null')
whatsapp_partner_id = fields.Many2one(comodel_name='res.partner', string="WhatsApp Partner", index='btree_not_null')
wa_account_id = fields.Many2one(comodel_name='whatsapp.account', string="WhatsApp Business Account")
whatsapp_channel_active = fields.Boolean('Is Whatsapp Channel Active', compute="_compute_whatsapp_channel_active")
_sql_constraints = [
('group_public_id_check',
"CHECK (channel_type = 'channel' OR channel_type = 'whatsapp' OR group_public_id IS NULL)",
'Group authorization and group auto-subscription are only supported on channels and whatsapp.'),
]
@api.constrains('channel_type', 'whatsapp_number')
def _check_whatsapp_number(self):
# constraint to check the whatsapp number for channel with type 'whatsapp'
missing_number = self.filtered(lambda channel: channel.channel_type == 'whatsapp' and not channel.whatsapp_number)
if missing_number:
raise ValidationError(
_("A phone number is required for WhatsApp channels %(channel_names)s",
channel_names=', '.join(missing_number)
))
# INHERITED CONSTRAINTS
@api.constrains('group_public_id', 'group_ids')
def _constraint_group_id_channel(self):
valid_channels = self.filtered(lambda channel: channel.channel_type == 'whatsapp')
super(DiscussChannel, self - valid_channels)._constraint_group_id_channel()
# NEW COMPUTES
@api.depends('last_wa_mail_message_id')
def _compute_whatsapp_channel_valid_until(self):
for channel in self:
channel.whatsapp_channel_valid_until = channel.last_wa_mail_message_id.create_date + timedelta(hours=24) \
if channel.channel_type == "whatsapp" and channel.last_wa_mail_message_id else False
@api.depends('whatsapp_channel_valid_until')
def _compute_whatsapp_channel_active(self):
for channel in self:
channel.whatsapp_channel_active = channel.whatsapp_channel_valid_until and \
channel.whatsapp_channel_valid_until > fields.Datetime.now()
# INHERITED COMPUTES
def _compute_group_public_id(self):
wa_channels = self.filtered(lambda channel: channel.channel_type == "whatsapp")
wa_channels.filtered(lambda channel: not channel.group_public_id).group_public_id = self.env.ref('base.group_user')
super(DiscussChannel, self - wa_channels)._compute_group_public_id()
# ------------------------------------------------------------
# MAILING
# ------------------------------------------------------------
def _get_notify_valid_parameters(self):
if self.channel_type == 'whatsapp':
return super()._get_notify_valid_parameters() | {'whatsapp_inbound_msg_uid'}
return super()._get_notify_valid_parameters()
def _notify_thread(self, message, msg_vals=False, **kwargs):
parent_msg_id = kwargs.pop('parent_msg_id') if 'parent_msg_id' in kwargs else False
recipients_data = super()._notify_thread(message, msg_vals=msg_vals, **kwargs)
if kwargs.get('whatsapp_inbound_msg_uid') and self.channel_type == 'whatsapp':
self.env['whatsapp.message'].create({
'mail_message_id': message.id,
'message_type': 'inbound',
'mobile_number': f'+{self.whatsapp_number}',
'msg_uid': kwargs['whatsapp_inbound_msg_uid'],
'parent_id': parent_msg_id,
'state': 'received',
'wa_account_id': self.wa_account_id.id,
})
if parent_msg_id:
self.env['whatsapp.message'].browse(parent_msg_id).state = 'replied'
return recipients_data
def message_post(self, *, message_type='notification', **kwargs):
new_msg = super().message_post(message_type=message_type, **kwargs)
if self.channel_type == 'whatsapp' and message_type == 'whatsapp_message':
if new_msg.author_id == self.whatsapp_partner_id:
self.last_wa_mail_message_id = new_msg
self._bus_send_store(
self, {"whatsapp_channel_valid_until": self.whatsapp_channel_valid_until}
)
if not new_msg.wa_message_ids:
whatsapp_message = self.env['whatsapp.message'].create({
'body': new_msg.body,
'mail_message_id': new_msg.id,
'message_type': 'outbound',
'mobile_number': f'+{self.whatsapp_number}',
'wa_account_id': self.wa_account_id.id,
})
whatsapp_message._send()
return new_msg
# ------------------------------------------------------------
# CONTROLLERS
# ------------------------------------------------------------
@api.returns('self')
def _get_whatsapp_channel(self, whatsapp_number, wa_account_id, sender_name=False, create_if_not_found=False, related_message=False):
""" Creates a whatsapp channel.
:param str whatsapp_number: whatsapp phone number of the customer. It should
be formatted according to whatsapp standards, aka {country_code}{national_number}.
:returns: whatsapp discussion discuss.channel
"""
# be somewhat defensive with number, as it is used in various flows afterwards
# notably in 'message_post' for the number, and called by '_process_messages'
base_number = whatsapp_number if whatsapp_number.startswith('+') else f'+{whatsapp_number}'
wa_number = base_number.lstrip('+')
wa_formatted = wa_phone_validation.wa_phone_format(
self.env.company,
number=base_number,
force_format="WHATSAPP",
raise_exception=False,
) or wa_number
related_record = False
responsible_partners = self.env['res.partner']
channel_domain = [
('whatsapp_number', '=', wa_formatted),
('wa_account_id', '=', wa_account_id.id)
]
if related_message:
related_record = self.env[related_message.model].browse(related_message.res_id)
responsible_partners = related_record._whatsapp_get_responsible(
related_message=related_message,
related_record=related_record,
whatsapp_account=wa_account_id,
).partner_id
channel = self.sudo().search(channel_domain, order='create_date desc', limit=1)
if responsible_partners:
channel = channel.filtered(lambda c: all(r in c.channel_member_ids.partner_id for r in responsible_partners))
partners_to_notify = responsible_partners
record_name = related_message.record_name
if not record_name and related_message.res_id:
record_name = self.env[related_message.model].browse(related_message.res_id).display_name
if not channel and create_if_not_found:
channel = self.sudo().with_context(tools.clean_context(self.env.context)).create({
'name': f"{wa_formatted} ({record_name})" if record_name else wa_formatted,
'channel_type': 'whatsapp',
'whatsapp_number': wa_formatted,
'whatsapp_partner_id': self.env['res.partner']._find_or_create_from_number(wa_formatted, sender_name).id,
'wa_account_id': wa_account_id.id,
})
partners_to_notify += channel.whatsapp_partner_id
if related_message:
# Add message in channel about the related document
info = _("Related %(model_name)s: ", model_name=self.env['ir.model']._get(related_message.model).display_name)
url = Markup('{base_url}/odoo/{model}/{res_id}').format(
base_url=self.get_base_url(), model=related_message.model, res_id=related_message.res_id)
related_record_name = related_message.record_name
if not related_record_name:
related_record_name = self.env[related_message.model].browse(related_message.res_id).display_name
channel.message_post(
body=Markup('<p>{info}<a target="_blank" href="{url}">{related_record_name}</a></p>').format(
info=info, url=url, related_record_name=related_record_name),
message_type='comment',
author_id=self.env.ref('base.partner_root').id,
subtype_xmlid='mail.mt_note',
)
if hasattr(related_record, 'message_post'):
# Add notification in document about the new message and related channel
info = _("A new WhatsApp channel is created for this document")
url = Markup('{base_url}/odoo/discuss.channel/{channel_id}').format(
base_url=self.get_base_url(), channel_id=channel.id)
related_record.message_post(
author_id=self.env.ref('base.partner_root').id,
body=Markup('<p>{info} <a target="_blank" class="o_whatsapp_channel_redirect"'
'data-oe-id="{channel_id}" href="{url}">{channel_name}</a></p>').format(
info=info, url=url, channel_id=channel.id, channel_name=channel.display_name),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
if partners_to_notify == channel.whatsapp_partner_id and wa_account_id.notify_user_ids.partner_id:
partners_to_notify += wa_account_id.notify_user_ids.partner_id
channel.channel_member_ids = [Command.clear()] + [Command.create({'partner_id': partner.id}) for partner in partners_to_notify]
channel._broadcast(partners_to_notify.ids)
return channel
def whatsapp_channel_join_and_pin(self):
""" Adds the current partner as a member of self channel and pins them if not already pinned. """
self.ensure_one()
if self.channel_type != 'whatsapp':
raise ValidationError(_('This join method is not possible for regular channels.'))
self.check_access('write')
current_partner = self.env.user.partner_id
member = self.channel_member_ids.filtered(lambda m: m.partner_id == current_partner)
if member:
if not member.is_pinned:
member.write({'unpin_dt': False})
else:
new_member = self.env['discuss.channel.member'].with_context(tools.clean_context(self.env.context)).sudo().create([{
'partner_id': current_partner.id,
'channel_id': self.id,
}])
message_body = Markup(f'<div class="o_mail_notification">{_("joined the channel")}</div>')
new_member.channel_id.message_post(body=message_body, message_type="notification", subtype_xmlid="mail.mt_comment")
self._bus_send_store(Store(new_member).add(self, {"memberCount": self.member_count}))
return Store(self).get_result()
# ------------------------------------------------------------
# OVERRIDE
# ------------------------------------------------------------
def _action_unfollow(self, partner=None, guest=None):
if partner and self.channel_type == "whatsapp" \
and next(
(member.partner_id for member in self.channel_member_ids if not member.partner_id.partner_share),
self.env["res.partner"]
) == partner:
msg = _("You can't leave this channel. As you are the owner of this WhatsApp channel, you can only delete it.")
partner._bus_send_transient_message(self, msg)
return
super()._action_unfollow(partner, guest)
def _to_store(self, store: Store):
super()._to_store(store)
for channel in self.filtered(lambda channel: channel.channel_type == "whatsapp"):
store.add(channel, {
"whatsapp_channel_valid_until": channel.whatsapp_channel_valid_until,
"whatsapp_partner_id": Store.one(channel.whatsapp_partner_id, only_id=True),
})
def _types_allowing_seen_infos(self):
return super()._types_allowing_seen_infos() + ["whatsapp"]
# ------------------------------------------------------------
# COMMANDS
# ------------------------------------------------------------
def execute_command_leave(self, **kwargs):
if self.channel_type == 'whatsapp':
self.action_unfollow()
else:
super().execute_command_leave(**kwargs)

View File

@ -0,0 +1,23 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo import api, models
class DiscussChannelMember(models.Model):
_inherit = 'discuss.channel.member'
@api.autovacuum
def _gc_unpin_whatsapp_channels(self):
""" Unpin read whatsapp channels with no activity for at least one day to
clean the operator's interface """
members = self.env['discuss.channel.member'].search([
('is_pinned', '=', True),
('last_seen_dt', '<=', datetime.now() - timedelta(days=1)),
('channel_id.channel_type', '=', 'whatsapp'),
])
members_to_be_unpinned = members.filtered(lambda m: m.message_unread_counter == 0)
members_to_be_unpinned.write({'unpin_dt': datetime.now()})
for member in members_to_be_unpinned:
member._bus_send("discuss.channel/unpin", {"id": member.channel_id.id})

View File

@ -0,0 +1,45 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ServerActions(models.Model):
""" Add WhatsApp option in server actions. """
_name = 'ir.actions.server'
_inherit = ['ir.actions.server']
# force insert before followers option
state = fields.Selection(selection_add=[
('whatsapp', 'Send WhatsApp'), ('followers',),
], ondelete={'whatsapp': 'cascade'})
# WhatsApp
wa_template_id = fields.Many2one(
'whatsapp.template', 'WhatsApp Template',
compute='_compute_wa_template_id',
ondelete='restrict', readonly=False, store=True,
domain="[('model_id', '=', model_id), ('status', '=', 'approved')]",
)
@api.depends('model_id', 'state')
def _compute_wa_template_id(self):
to_reset = self.filtered(
lambda act: act.state != 'whatsapp' or (act.model_id != act.wa_template_id.model_id)
)
if to_reset:
to_reset.wa_template_id = False
def _run_action_whatsapp_multi(self, eval_context=None):
if not self.wa_template_id or self._is_recompute():
return False
records = eval_context.get('records') or eval_context.get('record')
if not records:
return False
self.env['whatsapp.composer'].create({
'res_ids': records.ids,
'res_model': records._name,
'wa_template_id': self.wa_template_id.id,
})._send_whatsapp_template(force_send_by_cron=True)
return False

View File

@ -0,0 +1,41 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
from odoo.addons.mail.tools.discuss import Store
class MailMessage(models.Model):
_inherit = 'mail.message'
message_type = fields.Selection(
selection_add=[('whatsapp_message', 'WhatsApp')],
ondelete={'whatsapp_message': lambda recs: recs.write({'message_type': 'comment'})},
)
wa_message_ids = fields.One2many('whatsapp.message', 'mail_message_id', string='Related WhatsApp Messages')
def _post_whatsapp_reaction(self, reaction_content, partner_id):
self.ensure_one()
reaction_to_delete = self.reaction_ids.filtered(lambda r: r.partner_id == partner_id)
if reaction_to_delete:
content = reaction_to_delete.content
reaction_to_delete.unlink()
self._bus_send_reaction_group(content)
if reaction_content and self.id:
self.env['mail.message.reaction'].create({
'message_id': self.id,
'content': reaction_content,
'partner_id': partner_id.id,
})
self._bus_send_reaction_group(reaction_content)
def _to_store(self, store: Store, **kwargs):
super()._to_store(store, **kwargs)
if whatsapp_mail_messages := self.filtered(lambda m: m.message_type == "whatsapp_message"):
for whatsapp_message in (
self.env["whatsapp.message"]
.sudo()
.search([("mail_message_id", "in", whatsapp_mail_messages.ids)])
):
store.add(
whatsapp_message.mail_message_id, {"whatsappStatus": whatsapp_message.state}
)

View File

@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.addons.mail.tools.discuss import Store
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
def _thread_to_store(self, store: Store, /, *, request_list=None, **kwargs):
super()._thread_to_store(store, request_list=request_list, **kwargs)
if request_list:
store.add(
self,
{"canSendWhatsapp": self.env["whatsapp.template"]._can_use_whatsapp(self._name)},
as_thread=True,
)

View File

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo import exceptions, models, _
from odoo.tools import format_datetime
class BaseModel(models.AbstractModel):
_inherit = 'base'
def _whatsapp_get_portal_url(self):
""" List is defined here else we need to create bridge modules. """
if self._name in {
'sale.order',
'account.move',
'project.project',
'project.task',
'purchase.order',
'helpdesk.ticket',
} and hasattr(self, 'get_portal_url'):
self.ensure_one()
return self.get_portal_url()
contactus_page = self.env.ref('website.contactus_page', raise_if_not_found=False)
return contactus_page.url if contactus_page else False
def _whatsapp_get_responsible(self, related_message=False, related_record=False, whatsapp_account=False):
""" Try to find suitable responsible users for a record.
This is typically used when trying to find who to add to the discuss.channel created when
a customer replies to a sent 'whatsapp.template'. In short: who should be notified.
Heuristic is as follows:
- Try to find a 'user_id/user_ids' field on the record, use that as responsible if available;
- Always add the author of the original message
(If you send a template to a customer, you should be able to reply to his questions.)
- If nothing found, fallback on the first available among the following:
- The creator of the record
- The last editor of the record
- Ultimate fallback is the people configured as 'notify_user_ids' on the whatsapp account
For each of those, we only take into account active internal users, that are not the
superuser, to avoid having the responsible set to 'Odoobot' for automated processes.
This method can be overridden to force specific responsible users. """
self.ensure_one()
responsible_users = self.env['res.users']
def filter_suitable_users(user):
return user.active and user._is_internal() and not user._is_superuser()
for field in ['user_id', 'user_ids']:
if field in self._fields and self[field]:
responsible_users = self[field].filtered(filter_suitable_users)
if related_message:
# add the message author even if we already have a responsible
responsible_users |= related_message.author_id.user_ids.filtered(filter_suitable_users)
if responsible_users:
# do not go further if we found suitable users
return responsible_users
if related_message and not related_record:
related_record = self.env[related_message.model].browse(related_message.res_id)
if related_record:
responsible_users = related_record.create_uid.filtered(filter_suitable_users)
if not responsible_users:
responsible_users = related_record.write_uid.filtered(filter_suitable_users)
if not responsible_users:
if not whatsapp_account:
whatsapp_account = self.env['whatsapp.account'].search([], limit=1)
responsible_users = whatsapp_account.notify_user_ids
return responsible_users

View File

@ -0,0 +1,133 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
from odoo import exceptions, fields, models, _
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.whatsapp.tools import phone_validation as phone_validation_wa
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
wa_channel_count = fields.Integer(string='WhatsApp Channel Count', compute="_compute_wa_channel_count")
def _compute_wa_channel_count(self):
partner_channel_counts = {partner.id: 0 for partner in self}
member_count_by_partner = self.env['discuss.channel.member']._read_group(
domain=[
('channel_id.channel_type', '=', 'whatsapp'),
('partner_id', 'in', self.ids)
],
groupby=['partner_id'],
aggregates=['id:count'],
)
for partner, count in member_count_by_partner:
partner_channel_counts[partner.id] += count
for partner in self:
partner.wa_channel_count = partner_channel_counts[partner.id]
def _find_or_create_from_number(self, number, name=False):
""" Number should come currently from whatsapp and contain country info. """
search_number = number if number.startswith('+') else f'+{number}'
try:
formatted_number = phone_validation_wa.wa_phone_format(
self.env.company,
number=search_number,
force_format='E164',
raise_exception=True,
)
except Exception: # noqa: BLE001 don't want to crash in that point, whatever the issue
_logger.warning('WhatsApp: impossible to format incoming number %s, skipping partner creation', number)
formatted_number = False
if not number or not formatted_number:
return self.env['res.partner']
# find country / local number based on formatted number to ease future searches
region_data = phone_validation.phone_get_region_data_for_number(formatted_number)
number_country_code = region_data['code']
number_national_number = str(region_data['national_number'])
number_phone_code = int(region_data['phone_code'])
# search partner on INTL number, then fallback on national number
partners = self._search_on_phone_mobile("=", formatted_number)
if not partners:
partners = self._search_on_phone_mobile("=like", number_national_number)
if not partners:
# do not set a country if country code is not unique as we cannot guess
country = self.env['res.country'].search([('phone_code', '=', number_phone_code)])
if len(country) > 1:
country = country.filtered(lambda c: c.code.lower() == number_country_code.lower())
partners = self.env['res.partner'].create({
'country_id': country.id if country and len(country) == 1 else False,
'mobile': formatted_number,
'name': name or formatted_number,
})
partners._message_log(
body=_("Partner created by incoming WhatsApp message.")
)
return partners[0]
def _search_on_phone_mobile(self, operator, number):
""" Temporary hackish solution to better find partners based on numbers.
It is globally copied from '_search_phone_mobile_search' defined on
'mail.thread.phone' mixin. However a design decision led to not using
it in base whatsapp module (because stuff), hence not having
this search method nor the 'phone_sanitized' field. """
assert operator in {'=', '=like'}
number = number.strip()
if not number:
return self.browse()
if len(number) < self.env['mail.thread.phone']._phone_search_min_length:
raise exceptions.UserError(
_('Please enter at least 3 characters when searching a Phone/Mobile number.')
)
phone_fields = ['mobile', 'phone']
pattern = r'[\s\\./\(\)\-]'
sql_operator = "LIKE" if operator == "=like" else "="
if number.startswith(('+', '00')):
# searching on +32485112233 should also finds 0032485112233 (and vice versa)
# we therefore remove it from input value and search for both of them in db
where_str = ' OR '.join(
f"""partner.{phone_field} IS NOT NULL AND (
REGEXP_REPLACE(partner.{phone_field}, %s, '', 'g') {sql_operator} %s OR
REGEXP_REPLACE(partner.{phone_field}, %s, '', 'g') {sql_operator} %s
)"""
for phone_field in phone_fields
)
query = f"SELECT partner.id FROM {self._table} partner WHERE {where_str};"
term = re.sub(pattern, '', number[1 if number.startswith('+') else 2:])
if operator == "=like":
term = f'%{term}'
self._cr.execute(
query, (pattern, '00' + term, pattern, '+' + term) * len(phone_fields)
)
else:
where_str = ' OR '.join(
f"(partner.{phone_field} IS NOT NULL AND REGEXP_REPLACE(partner.{phone_field}, %s, '', 'g') {sql_operator} %s)"
for phone_field in phone_fields
)
query = f"SELECT partner.id FROM {self._table} partner WHERE {where_str};"
term = re.sub(pattern, '', number)
if operator == "=like":
term = f'%{term}'
self._cr.execute(query, (pattern, term) * len(phone_fields))
res = self._cr.fetchall()
return self.browse([r[0] for r in res])
def action_open_partner_wa_channels(self):
return {
'name': _('WhatsApp Chats'),
'type': 'ir.actions.act_window',
'domain': [('channel_type', '=', 'whatsapp'), ('channel_partner_ids', 'in', self.ids)],
'res_model': 'discuss.channel',
'views': [(self.env.ref('whatsapp.discuss_channel_view_list_whatsapp').id, 'list')],
}

View File

@ -0,0 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResUsersSettings(models.Model):
_inherit = 'res.users.settings'
is_discuss_sidebar_category_whatsapp_open = fields.Boolean(
string='WhatsApp Category Open', default=True,
help="If checked, the WhatsApp category is open in the discuss sidebar")

View File

@ -0,0 +1,243 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import mimetypes
import secrets
import string
from markupsafe import Markup
from odoo import 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.tools import plaintext2html
_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)
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)
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')
_sql_constraints = [
('phone_uid_unique', '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', '=', tmpl.id)])
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)
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()
except WhatsAppError as e:
raise UserError(str(e))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': _("Credentials look good!"),
}
}
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 _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()
whatsapp_message = self.env['whatsapp.message'].sudo().search(
[
('mobile_number_formatted', '=', sender_mobile_formatted),
('wa_account_id', '=', self.id),
('wa_template_id', '!=', False),
('state', 'not in', ['outgoing', 'error', 'cancel']),
], limit=1, order='id desc')
return self.env['discuss.channel'].sudo()._get_whatsapp_channel(
whatsapp_number=sender_mobile_formatted,
wa_account_id=self,
sender_name=sender_name,
create_if_not_found=create_if_not_found,
related_message=whatsapp_message.mail_message_id,
)
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
sender_name = value.get('contacts', [{}])[0].get('profile', {}).get('name')
sender_mobile = messages['from']
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(sender_mobile, sender_name=sender_name, 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_name=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)

View File

@ -0,0 +1,453 @@
# 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,
},
)

View File

@ -0,0 +1,934 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import re
import mimetypes
from markupsafe import Markup
from odoo import api, models, fields, _, Command
from odoo.addons.whatsapp.tools.lang_list import Languages
from odoo.addons.whatsapp.tools.whatsapp_api import WhatsAppApi
from odoo.addons.whatsapp.tools.whatsapp_exception import WhatsAppError
from odoo.exceptions import UserError, ValidationError, AccessError
from odoo.tools import plaintext2html
from odoo.tools.safe_eval import safe_eval
LATITUDE_LONGITUDE_REGEX = r'^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$'
COMMON_WHATSAPP_PHONE_SAFE_FIELDS = {
'mobile',
'phone',
'phone_sanitized',
'partner_id.mobile',
'partner_id.phone',
'phone_sanitized.phone',
'x_studio_mobile',
'x_studio_phone',
'x_studio_partner_id.mobile',
'x_studio_partner_id.phone',
'x_studio_partner_id.phone_sanitized',
}
class WhatsAppTemplate(models.Model):
_name = 'whatsapp.template'
_inherit = ['mail.thread']
_description = 'WhatsApp Template'
_order = 'sequence asc, id'
@api.model
def _get_default_wa_account_id(self):
first_account = self.env['whatsapp.account'].search([
('allowed_company_ids', 'in', self.env.companies.ids)], limit=1)
return first_account.id if first_account else False
@api.model
def _get_model_selection(self):
""" Available models are all models, as even transient models could have
templates associated (e.g. payment.link.wizard) """
return [
(model.model, model.name)
for model in self.env['ir.model'].sudo().search([])
]
name = fields.Char(string="Name", tracking=True)
template_name = fields.Char(
string="Template Name",
compute='_compute_template_name', readonly=False, store=True,
copy=False)
sequence = fields.Integer(required=True, default=0)
active = fields.Boolean(default=True)
wa_account_id = fields.Many2one(
comodel_name='whatsapp.account', string="Account", default=_get_default_wa_account_id,
ondelete="cascade")
wa_template_uid = fields.Char(string="WhatsApp Template ID", copy=False)
error_msg = fields.Char(string="Error Message")
model_id = fields.Many2one(
string='Applies to', comodel_name='ir.model',
default=lambda self: self.env['ir.model']._get_id('res.partner'),
ondelete='cascade', required=True, store=True,
tracking=1)
model = fields.Char(
string='Related Document Model',
related='model_id.model',
precompute=True, store=True, readonly=True)
phone_field = fields.Char(
string='Phone Field', compute='_compute_phone_field',
precompute=True, readonly=False, required=True, store=True)
lang_code = fields.Selection(string="Language", selection=Languages, default='en', required=True)
template_type = fields.Selection([
('authentication', 'Authentication'),
('marketing', 'Marketing'),
('utility', 'Utility')], string="Category", default='marketing', tracking=True, required=True,
help="Authentication - One-time passwords that your customers use to authenticate a transaction or login.\n"
"Marketing - Promotions or information about your business, products or services. Or any message that isn't utility or authentication.\n"
"Utility - Messages about a specific transaction, account, order or customer request.")
status = fields.Selection([
('draft', 'Draft'),
('pending', 'Pending'),
('in_appeal', 'In Appeal'),
('approved', 'Approved'),
('paused', 'Paused'),
('disabled', 'Disabled'),
('rejected', 'Rejected'),
('pending_deletion', 'Pending Deletion'),
('deleted', 'Deleted'),
('limit_exceeded', 'Limit Exceeded')], string="Status", default='draft', copy=False, tracking=True)
quality = fields.Selection([
('none', 'None'),
('red', 'Red'),
('yellow', 'Yellow'),
('green', 'Green')], string="Quality", default='none', copy=False, tracking=True)
allowed_user_ids = fields.Many2many(
comodel_name='res.users', string="Users",
domain=[('share', '=', False)])
body = fields.Text(string="Template body", tracking=True)
header_type = fields.Selection([
('none', 'None'),
('text', 'Text'),
('image', 'Image'),
('video', 'Video'),
('document', 'Document'),
('location', 'Location')], string="Header Type", default='none')
header_text = fields.Char(string="Template Header Text", size=60)
header_attachment_ids = fields.Many2many(
'ir.attachment', string="Template Static Header",
copy=False) # keep False to avoid linking attachments; we have to copy them instead
footer_text = fields.Char(string="Footer Message")
report_id = fields.Many2one(
comodel_name='ir.actions.report', string="Report",
compute="_compute_report_id", readonly=False, store=True,
domain="[('model', '=', model)]", tracking=True)
variable_ids = fields.One2many(
'whatsapp.template.variable', 'wa_template_id', string="Template Variables",
store=True, compute='_compute_variable_ids', precompute=True, readonly=False,
copy=False) # done with custom code due to buttons variables
button_ids = fields.One2many(
'whatsapp.template.button', 'wa_template_id', string="Buttons",
copy=True) # will copy their variables
has_invalid_button_number = fields.Boolean(compute="_compute_has_invalid_button_number")
messages_count = fields.Integer(string="Messages Count", compute='_compute_messages_count')
has_action = fields.Boolean(string="Has Action", compute='_compute_has_action')
_sql_constraints = [
('unique_name_account_template', 'unique(template_name, lang_code, wa_account_id)', "Duplicate template is not allowed for one Meta account.")
]
@api.constrains('header_text')
def _check_header_text(self):
for tmpl in self.filtered(lambda l: l.header_type == 'text'):
header_variables = list(re.findall(r'{{[1-9][0-9]*}}', tmpl.header_text))
if len(header_variables) > 1 or (header_variables and header_variables[0] != '{{1}}'):
raise ValidationError(
_("The Header Text must either contain no variable or the first one {{1}}.")
)
@api.constrains('phone_field', 'model')
def _check_phone_field(self):
is_system = self.env.user.has_group('base.group_system')
for tmpl in self.filtered('phone_field'):
model = self.env[tmpl.model]
if not is_system:
if not model.has_access('read'):
model_description = self.env['ir.model']._get(tmpl.model).display_name
raise AccessError(
_("You can not select field of %(model)s.", model=model_description)
)
safe_fields = set(COMMON_WHATSAPP_PHONE_SAFE_FIELDS)
if hasattr(model, '_wa_get_safe_phone_fields'):
safe_fields |= set(model._wa_get_safe_phone_fields())
if tmpl.phone_field not in safe_fields:
raise AccessError(
_("You are not allowed to use %(field)s in phone field, contact your administrator to configure it.",
field=tmpl.phone_field)
)
try:
model._find_value_from_field_path(tmpl.phone_field)
except UserError as err:
raise ValidationError(
_("'%(field)s' does not seem to be a valid field path on %(model)s",
field=tmpl.phone_field,
model=tmpl.model)
) from err
@api.constrains('header_attachment_ids', 'header_type', 'report_id')
def _check_header_attachment_ids(self):
templates_with_attachments = self.filtered('header_attachment_ids')
for tmpl in templates_with_attachments:
if len(tmpl.header_attachment_ids) > 1:
raise ValidationError(_('You may only use one header attachment for each template'))
if tmpl.header_type not in ['image', 'video', 'document']:
raise ValidationError(_("Only templates using media header types may have header documents"))
if not any(tmpl.header_attachment_ids.mimetype in mimetypes for mimetypes in self.env['whatsapp.message']._SUPPORTED_ATTACHMENT_TYPE[tmpl.header_type]):
raise ValidationError(_("File type %(file_type)s not supported for header type %(header_type)s",
file_type=tmpl.header_attachment_ids.mimetype, header_type=tmpl.header_type))
for tmpl in self - templates_with_attachments:
if tmpl.header_type == 'document' and not tmpl.report_id:
raise ValidationError(_("Header document or report is required"))
if tmpl.header_type in ['image', 'video']:
raise ValidationError(_("Header document is required"))
@api.constrains('button_ids', 'variable_ids')
def _check_buttons(self):
for tmpl in self:
if len(tmpl.button_ids) > 10:
raise ValidationError(_('Maximum 10 buttons allowed.'))
if len(tmpl.button_ids.filtered(lambda button: button.button_type == 'url')) > 2:
raise ValidationError(_('Maximum 2 URL buttons allowed.'))
if len(tmpl.button_ids.filtered(lambda button: button.button_type == 'phone_number')) > 1:
raise ValidationError(_('Maximum 1 Call Number button allowed.'))
@api.constrains('variable_ids')
def _check_body_variables(self):
for template in self:
variables = template.variable_ids.filtered(lambda variable: variable.line_type == 'body')
free_text_variables = variables.filtered(lambda variable: variable.field_type == 'free_text')
if len(free_text_variables) > 10:
raise ValidationError(_('Only 10 free text is allowed in body of template'))
variable_indices = sorted(var._extract_variable_index() for var in variables)
if len(variable_indices) > 0 and (variable_indices[0] != 1 or variable_indices[-1] != len(variables)):
missing = next(
(index for index in range(1, len(variables)) if variable_indices[index - 1] + 1 != variable_indices[index]),
0) + 1
raise ValidationError(_('Body variables should start at 1 and not skip any number, missing %d', missing))
@api.constrains('header_type', 'variable_ids')
def _check_header_variables(self):
for template in self:
location_vars = template.variable_ids.filtered(lambda var: var.line_type == 'location')
text_vars = template.variable_ids.filtered(lambda var: var.line_type == 'header')
if template.header_type == 'location' and len(location_vars) != 4:
raise ValidationError(_('When using a "location" header, there should 4 location variables not %(count)d.',
count=len(location_vars)))
elif template.header_type != 'location' and location_vars:
raise ValidationError(_('Location variables should only exist when a "location" header is selected.'))
if len(text_vars) > 1:
raise ValidationError(_('There should be at most 1 variable in the header of the template.'))
if text_vars and text_vars._extract_variable_index() != 1:
raise ValidationError(_('Free text variable in the header should be {{1}}'))
@api.constrains('model')
def _check_model(self):
for template in self:
if self.env['ir.actions.server'].sudo().search_count([('wa_template_id', '=', template.id), ('model_name', '!=', template.model)]):
raise UserError(_('You cannot modify a template model when it is linked to server actions.'))
#=====================================================
# Compute Methods
#=====================================================
@api.depends('model')
def _compute_phone_field(self):
to_reset = self.filtered(lambda template: not template.model)
if to_reset:
to_reset.phone_field = False
for template in self.filtered('model'):
if template.phone_field and template.phone_field in self.env[template.model]._fields:
continue
if 'mobile' in self.env[template.model]._fields:
template.phone_field = 'mobile'
elif 'phone' in self.env[template.model]._fields:
template.phone_field = 'phone'
@api.depends('name', 'status', 'wa_template_uid')
def _compute_template_name(self):
for template in self:
if not template.template_name or (template.status == 'draft' and not template.wa_template_uid):
slugify = self.env['ir.http']._slugify
template.template_name = re.sub(r'\W+', '_', slugify(template.name or ''))
@api.depends('model')
def _compute_model_id(self):
self.filtered(lambda tpl: not tpl.model).model_id = False
for template in self.filtered('model'):
template.model_id = self.env['ir.model']._get_id(template.model)
@api.depends('model')
def _compute_report_id(self):
""" Reset if model changes to avoid ill defined reports """
to_reset = self.filtered(lambda tpl: tpl.report_id.model != tpl.model)
if to_reset:
to_reset.report_id = False
@api.depends('header_type', 'header_text', 'body')
def _compute_variable_ids(self):
"""compute template variable according to header text, body and buttons"""
for tmpl in self:
to_delete = self.env["whatsapp.template.variable"]
to_keep = self.env["whatsapp.template.variable"]
to_create_values = []
header_variables = list(re.findall(r'{{[1-9][0-9]*}}', tmpl.header_text or ''))
body_variables = set(re.findall(r'{{[1-9][0-9]*}}', tmpl.body or ''))
# if there is header text
existing_header_text_variable = tmpl.variable_ids.filtered(lambda line: line.line_type == 'header')
if header_variables and not existing_header_text_variable:
to_create_values.append({'name': header_variables[0], 'line_type': 'header', 'wa_template_id': tmpl.id})
elif not header_variables and existing_header_text_variable:
to_delete += existing_header_text_variable
elif existing_header_text_variable:
to_keep += existing_header_text_variable
# if the header is a location
existing_header_location_variables = tmpl.variable_ids.filtered(lambda line: line.line_type == 'location')
if tmpl.header_type == 'location' and not existing_header_location_variables:
to_create_values += [
{'name': 'name', 'line_type': 'location', 'wa_template_id': tmpl.id},
{'name': 'address', 'line_type': 'location', 'wa_template_id': tmpl.id},
{'name': 'latitude', 'line_type': 'location', 'wa_template_id': tmpl.id},
{'name': 'longitude', 'line_type': 'location', 'wa_template_id': tmpl.id}
]
elif tmpl.header_type != 'location' and existing_header_location_variables:
to_delete += existing_header_location_variables
elif existing_header_location_variables:
to_keep += existing_header_location_variables
# body
existing_body_variables = tmpl.variable_ids.filtered(lambda line: line.line_type == 'body')
new_body_variable_names = [var_name for var_name in body_variables if var_name not in existing_body_variables.mapped('name')]
deleted_body_variables = existing_body_variables.filtered(lambda var: var.name not in body_variables)
to_create_values += [{'name': var_name, 'line_type': 'body', 'wa_template_id': tmpl.id} for var_name in set(new_body_variable_names)]
to_delete += deleted_body_variables
to_keep += existing_body_variables - deleted_body_variables
# if to_delete:
# to_delete.unlink()
tmpl.variable_ids = [(3, to_remove.id) for to_remove in to_delete] + [(0, 0, vals) for vals in to_create_values]
@api.depends('button_ids')
def _compute_has_invalid_button_number(self):
for template in self:
template.has_invalid_button_number = any(template.button_ids.mapped('has_invalid_number'))
@api.depends('model_id')
def _compute_has_action(self):
for tmpl in self:
action = self.env['ir.actions.act_window'].sudo().search([('res_model', '=', 'whatsapp.composer'), ('binding_model_id', '=', tmpl.model_id.id)])
if action:
tmpl.has_action = True
else:
tmpl.has_action = False
def _compute_messages_count(self):
messages_by_template = dict(self.env['whatsapp.message']._read_group(
[('wa_template_id', 'in', self.ids)],
groupby=['wa_template_id'],
aggregates=['__count'],
))
for tmpl in self:
tmpl.messages_count = messages_by_template.get(tmpl, 0)
@api.depends('name', 'wa_account_id')
def _compute_display_name(self):
for template in self:
template.display_name = _('%(template_name)s [%(account_name)s]',
template_name=template.name,
account_name=template.wa_account_id.name
) if template.wa_account_id.name else template.name
@api.onchange('header_type')
def _onchange_header_type(self):
toreset_attachments = self.filtered(lambda t: t.header_type not in {"image", "video", "document"})
if toreset_attachments:
toreset_attachments.header_attachment_ids = [(5, 0)]
toreset_attachments.report_id = False
toreset_text = self.filtered(lambda t: t.header_type != "text")
if toreset_text:
toreset_text.header_text = False
@api.onchange('header_attachment_ids')
def _onchange_header_attachment_ids(self):
for template in self:
template.header_attachment_ids.res_id = template.id
template.header_attachment_ids.res_model = template._name
@api.onchange('wa_account_id')
def _onchange_wa_account_id(self):
"""Avoid carrying remote sync data when changing account."""
self.status = 'draft'
self.quality = 'none'
self.wa_template_uid = False
#===================================================================
# CRUD
#===================================================================
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
# stable backward compatible change for model fields
if vals.get('model_id'):
vals['model'] = self.env['ir.model'].sudo().browse(vals[('model_id')]).model
records = super().create(vals_list)
# the model of the variable might have been changed with x2many commands
records.variable_ids._check_field_name()
# update the attachment res_id for new records
records._onchange_header_attachment_ids()
return records
def write(self, vals):
if vals.get("model_id"):
vals["model"] = self.env['ir.model'].sudo().browse(vals["model_id"]).model
res = super().write(vals)
# Model change: explicitly check for field access. Other changes at variable
# level are checked by '_check_field_name' constraint.
if 'model_id' in vals:
self.variable_ids._check_field_name()
return res
def copy_data(self, default=None):
default = {} if default is None else default
values_list = super().copy_data(default=default)
for values, template in zip(values_list, self):
if not values.get("name"):
values["name"] = _('%(original_name)s (copy)', original_name=template.name)
if not values.get("template_name"):
values["template_name"] = f'{template.template_name}_copy'
if not values.get('header_attachment_ids') and template.header_attachment_ids:
values['header_attachment_ids'] = [
(0, 0, att.copy_data(default={'res_id': False})[0])
for att in template.header_attachment_ids
]
if template.variable_ids:
variable_commands = values.get('variable_ids', []) + [
(0, 0, {
'button_id': False,
'demo_value': variable.demo_value,
'field_name': variable.field_name,
'field_type': variable.field_type,
'line_type': variable.line_type,
'name': variable.name,
})
for variable in template.variable_ids if not variable.button_id
]
if variable_commands:
values['variable_ids'] = variable_commands
return values_list
#===================================================================
# Register template to whatsapp
#===================================================================
def _get_template_head_component(self, file_handle):
"""Return header component according to header type for template registration to whatsapp"""
if self.header_type == 'none':
return None
head_component = {'type': 'HEADER', 'format': self.header_type.upper()}
if self.header_type == 'text' and self.header_text:
head_component['text'] = self.header_text
header_params = self.variable_ids.filtered(lambda line: line.line_type == 'header')
if header_params:
head_component['example'] = {'header_text': header_params.mapped('demo_value')}
elif self.header_type in ['image', 'video', 'document']:
head_component['example'] = {
'header_handle': [file_handle]
}
return head_component
def _get_template_body_component(self):
"""Return body component for template registration to whatsapp"""
if not self.body:
return None
body_component = {'type': 'BODY', 'text': self.body}
body_params = self.variable_ids.filtered(lambda line: line.line_type == 'body')
if body_params:
body_component['example'] = {'body_text': [body_params.mapped('demo_value')]}
return body_component
def _get_template_button_component(self):
"""Return button component for template registration to whatsapp"""
if not self.button_ids:
return None
buttons = []
for button in self.button_ids:
button_data = {
'type': button.button_type.upper(),
'text': button.name
}
if button.button_type == 'url':
button_data.update(self._get_url_button_data(button))
elif button.button_type == 'phone_number':
button_data['phone_number'] = button.call_number
buttons.append(button_data)
return {'type': 'BUTTONS', 'buttons': buttons}
def _get_url_button_data(self, button):
button_data = {'url': button.website_url}
if button.url_type == 'dynamic':
button_data['url'] += '{{1}}'
button_data['example'] = button.variable_ids[0].demo_value
return button_data
def _get_template_footer_component(self):
if not self.footer_text:
return None
return {'type': 'FOOTER', 'text': self.footer_text}
def _get_sample_record(self):
return self.env[self.model].search([], limit=1)
def button_submit_template(self):
"""Register template to WhatsApp Business Account """
self.ensure_one()
if not self.template_type:
raise ValidationError(_("Template category is missing"))
wa_api = WhatsAppApi(self.wa_account_id)
attachment = False
if self.header_type in ('image', 'video', 'document'):
if self.header_type == 'document' and self.report_id:
record = self._get_sample_record()
if not record:
raise ValidationError(_("There is no record for preparing demo pdf in model %(model)s", model=self.model_id.name))
attachment = self._generate_attachment_from_report(record)
else:
attachment = self.header_attachment_ids
if not attachment:
raise ValidationError("Header Document is missing")
file_handle = False
if attachment:
try:
file_handle = wa_api._upload_demo_document(attachment)
except WhatsAppError as e:
raise UserError(str(e))
components = [self._get_template_body_component()]
components += [comp for comp in (
self._get_template_head_component(file_handle),
self._get_template_button_component(),
self._get_template_footer_component()) if comp]
json_data = json.dumps({
'name': self.template_name,
'language': self.lang_code,
'category': self.template_type.upper(),
'components': components,
})
try:
if self.wa_template_uid:
wa_api._submit_template_update(json_data, self.wa_template_uid)
self.status = 'pending'
else:
response = wa_api._submit_template_new(json_data)
self.write({
'wa_template_uid': response['id'],
'status': response['status'].lower()
})
except WhatsAppError as we:
raise UserError(str(we))
#===================================================================
# Sync template from whatsapp
#===================================================================
def button_sync_template(self):
"""Sync template from WhatsApp Business Account """
self.ensure_one()
wa_api = WhatsAppApi(self.wa_account_id)
try:
response = wa_api._get_template_data(wa_template_uid=self.wa_template_uid)
except WhatsAppError as e:
raise ValidationError(str(e))
if response.get('id'):
self._update_template_from_response(response)
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
@api.model
def _create_template_from_response(self, remote_template_vals, wa_account):
template_vals = self._get_template_vals_from_response(remote_template_vals, wa_account)
template_vals['variable_ids'] = [Command.create(var) for var in template_vals['variable_ids']]
for button in template_vals['button_ids']:
button['variable_ids'] = [Command.create(var) for var in button['variable_ids']]
template_vals['button_ids'] = [Command.create(button) for button in template_vals['button_ids']]
template_vals['header_attachment_ids'] = [Command.create(attachment) for attachment in template_vals['header_attachment_ids']]
return template_vals
def _get_additional_button_values(self, button):
return {}
def _update_template_from_response(self, remote_template_vals):
self.ensure_one()
update_fields = ('body', 'header_type', 'header_text', 'footer_text', 'lang_code', 'template_type', 'status', 'quality')
template_vals = self._get_template_vals_from_response(remote_template_vals, self.wa_account_id)
update_vals = {field: template_vals[field] for field in update_fields}
# variables should be preserved instead of overwritten to keep odoo-specific data like fields
variable_ids = []
existing_template_variables = {(variable_id.name, variable_id.line_type): variable_id.id for variable_id in self.variable_ids}
for variable_vals in template_vals['variable_ids']:
if not existing_template_variables.pop((variable_vals['name'], variable_vals['line_type']), False):
variable_ids.append(Command.create(variable_vals))
variable_ids.extend([Command.delete(to_remove) for to_remove in existing_template_variables.values()])
update_vals['variable_ids'] = variable_ids
for button in template_vals['button_ids']:
button['variable_ids'] = [Command.create(var) for var in button['variable_ids']]
additional_button_vals = self._get_additional_button_values(button)
button.update(additional_button_vals)
update_vals['button_ids'] = [Command.clear()] + [Command.create(button) for button in template_vals['button_ids']]
if not self.header_attachment_ids or self.header_type != template_vals['header_type']:
new_attachment_commands = [Command.create(attachment) for attachment in template_vals['header_attachment_ids']]
update_vals['header_attachment_ids'] = [Command.clear()] + new_attachment_commands
self.write(update_vals)
def _get_template_vals_from_response(self, remote_template_vals, wa_account):
"""Get dictionary of field: values from whatsapp template response json.
Relational fields will use arrays instead of commands.
"""
quality_score = remote_template_vals['quality_score']['score'].lower()
template_vals = {
'body': False,
'button_ids': [],
'footer_text': False,
'header_text': False,
'header_attachment_ids': [],
'header_type': 'none',
'lang_code': remote_template_vals['language'],
'name': remote_template_vals['name'].replace("_", " ").title(),
'quality': 'none' if quality_score == 'unknown' else quality_score,
'status': remote_template_vals['status'].lower(),
'template_name': remote_template_vals['name'],
'template_type': remote_template_vals['category'].lower(),
'variable_ids': [],
'wa_account_id': wa_account.id,
'wa_template_uid': int(remote_template_vals['id']),
}
for component in remote_template_vals['components']:
component_type = component['type']
if component_type == 'HEADER':
template_vals['header_type'] = component['format'].lower()
if component['format'] == 'TEXT':
template_vals['header_text'] = component['text']
if 'example' in component:
for index, example_value in enumerate(component['example'].get('header_text', [])):
template_vals['variable_ids'].append({
'name': '{{%s}}' % (index + 1),
'demo_value': example_value,
'line_type': 'header',
})
elif component['format'] == 'LOCATION':
for location_val in ['name', 'address', 'latitude', 'longitude']:
template_vals['variable_ids'].append({
'name': location_val,
'line_type': 'location',
})
elif component['format'] in ('IMAGE', 'VIDEO', 'DOCUMENT'):
document_url = component.get('example', {}).get('header_handle', [False])[0]
if document_url:
wa_api = WhatsAppApi(wa_account)
data, mimetype = wa_api._get_header_data_from_handle(document_url)
extension = mimetypes.guess_extension(mimetype)
else:
data = b'AAAA'
extension, mimetype = {
'IMAGE': ('jpg', 'image/jpeg'),
'VIDEO': ('mp4', 'video/mp4'),
'DOCUMENT': ('pdf', 'application/pdf')
}[component['format']]
template_vals['header_attachment_ids'] = [{
'name': f'{template_vals["template_name"]}{extension}',
'res_model': self._name,
'res_id': self.ids[0] if self else False,
'raw': data,
'mimetype': mimetype,
}]
elif component_type == 'BODY':
template_vals['body'] = component['text']
if 'example' in component:
for index, example_value in enumerate(component['example'].get('body_text', [[]])[0]):
template_vals['variable_ids'].append({
'name': '{{%s}}' % (index + 1),
'demo_value': example_value,
'line_type': 'body',
})
elif component_type == 'FOOTER':
template_vals['footer_text'] = component['text']
elif component_type == 'BUTTONS':
for index, button in enumerate(component['buttons']):
if button['type'] in ('URL', 'PHONE_NUMBER', 'QUICK_REPLY'):
button_vals = {
'sequence': index,
'name': button['text'],
'button_type': button['type'].lower(),
'call_number': button.get('phone_number'),
'website_url': button.get('url').replace('{{1}}', '') if button.get('url') else None,
'url_type': button.get('example', []) and 'dynamic' or 'static',
'variable_ids': []
}
for example_index, example_value in enumerate(button.get('example', [])):
button_vals['variable_ids'].append({
'name': '{{%s}}' % (example_index + 1),
'demo_value': example_value,
'line_type': 'button',
})
template_vals['button_ids'].append(button_vals)
return template_vals
#========================================================================
# Send WhatsApp message using template
#========================================================================
def _get_header_component(self, free_text_json, template_variables_value, attachment):
""" Prepare header component for sending WhatsApp template message"""
header = []
header_type = self.header_type
if header_type == 'text' and template_variables_value.get('header-{{1}}'):
value = (free_text_json or {}).get('header_text') or template_variables_value.get('header-{{1}}') or ' '
header = {
'type': 'header',
'parameters': [{'type': 'text', 'text': value}]
}
elif header_type in ['image', 'video', 'document']:
header = {
'type': 'header',
'parameters': [self.env['whatsapp.message']._prepare_attachment_vals(attachment, wa_account_id=self.wa_account_id)]
}
elif header_type == 'location':
header = {
'type': 'header',
'parameters': [self._prepare_location_vals(template_variables_value)]
}
return header
def _prepare_location_vals(self, template_variables_value):
""" Prepare location values for sending WhatsApp template message having header type location"""
self._check_location_latitude_longitude(template_variables_value.get('location-latitude'), template_variables_value.get('location-longitude'))
return {
'type': 'location',
'location': {
'name': template_variables_value.get('location-name'),
'address': template_variables_value.get('location-address'),
'latitude': template_variables_value.get('location-latitude'),
'longitude': template_variables_value.get('location-longitude'),
}
}
def _get_body_component(self, free_text_json, template_variables_value):
""" Prepare body component for sending WhatsApp template message"""
if not self.variable_ids:
return None
parameters = []
free_text_count = 1
for body_val in self.variable_ids.filtered(lambda line: line.line_type == 'body'):
free_text_value = body_val.field_type == 'free_text' and free_text_json.get(f'free_text_{free_text_count}') or False
parameters.append({
'type': 'text',
'text': free_text_value or template_variables_value.get(f'{body_val.line_type}-{body_val.name}') or ' '
})
if body_val.field_type == 'free_text':
free_text_count += 1
return {'type': 'body', 'parameters': parameters}
def _get_button_components(self, free_text_json, template_variables_value):
""" Prepare button component for sending WhatsApp template message"""
components = []
if not self.variable_ids:
return components
dynamic_buttons = self.button_ids._filter_dynamic_buttons()
dynamic_buttons = dynamic_buttons.sorted(lambda btn: btn.sequence)
dynamic_index = {button: i for i, button in enumerate(self.button_ids)}
free_text_index = 1
for button in dynamic_buttons:
button_var = button.variable_ids[0]
dynamic_url = button.website_url
if button_var.field_type == 'free_text':
value = free_text_json.get(f'button_dynamic_url_{free_text_index}') or ' '
free_text_index += 1
else:
value = template_variables_value.get(f'button-{button.name}') or ' '
value = value.replace(dynamic_url, '').lstrip('/') # / is implicit
components.append({
'type': 'button',
'sub_type': 'url',
'index': dynamic_index.get(button),
'parameters': [{'type': 'text', 'text': value}]
})
return components
def _get_send_template_vals(self, record, whatsapp_message):
"""Prepare JSON dictionary for sending WhatsApp template message"""
self.ensure_one()
free_text_json = whatsapp_message.free_text_json
attachment = whatsapp_message.mail_message_id.attachment_ids
components = []
template_variables_value = self.variable_ids._get_variables_value(record)
# generate attachment
if not attachment and self.report_id:
attachment = self._generate_attachment_from_report(record)
if not attachment and self.header_attachment_ids:
attachment = self.header_attachment_ids[0]
# generate content
header = self._get_header_component(free_text_json=free_text_json, attachment=attachment, template_variables_value=template_variables_value)
body = self._get_body_component(free_text_json=free_text_json, template_variables_value=template_variables_value)
buttons = self._get_button_components(free_text_json=free_text_json, template_variables_value=template_variables_value)
if header:
components.append(header)
if body:
components.append(body)
components.extend(buttons)
template_vals = {
'name': self.template_name,
'language': {'code': self.lang_code},
}
if components:
template_vals['components'] = components
return template_vals, attachment
def button_reset_to_draft(self):
for tmpl in self:
tmpl.write({'status': 'draft'})
def action_open_messages(self):
self.ensure_one()
return {
'name': _("Message Statistics Of %(template_name)s", template_name=self.name),
'view_mode': 'list,form,graph',
'res_model': 'whatsapp.message',
'domain': [('wa_template_id', '=', self.id)],
'type': 'ir.actions.act_window',
}
def button_create_action(self):
""" Create action for sending WhatsApp template message in model defined in template. It will be used in bulk sending"""
self.check_access('write')
actions = self.env['ir.actions.act_window'].sudo().search([
('res_model', '=', 'whatsapp.composer'),
('binding_model_id', 'in', self.model_id.ids)
])
actions = self.env['ir.actions.act_window'].sudo().create([
{
'binding_model_id': model.id,
'name': _('WhatsApp Message'),
'res_model': 'whatsapp.composer',
'target': 'new',
'type': 'ir.actions.act_window',
'view_mode': 'form',
}
for model in (self.model_id - actions.binding_model_id)
])
return actions
def button_delete_action(self):
self.check_access('write')
self.env['ir.actions.act_window'].sudo().search([
('res_model', '=', 'whatsapp.composer'),
('binding_model_id', 'in', self.model_id.ids)
]).unlink()
def _generate_attachment_from_report(self, record=False):
"""Create attachment from report if relevant"""
if record and self.header_type == 'document' and self.report_id:
report_content, report_format = self.report_id._render_qweb_pdf(self.report_id, record.id)
if self.report_id.print_report_name:
report_name = safe_eval(self.report_id.print_report_name, {'object': record}) + '.' + report_format
else:
report_name = self.display_name + '.' + report_format
return self.env['ir.attachment'].create({
'name': report_name,
'raw': report_content,
'mimetype': 'application/pdf',
})
return self.env['ir.attachment']
def _check_location_latitude_longitude(self, latitude, longitude):
if not re.match(LATITUDE_LONGITUDE_REGEX, f"{latitude}, {longitude}"):
raise ValidationError(
_("Location Latitude and Longitude %(latitude)s / %(longitude)s is not in proper format.",
latitude=latitude, longitude=longitude)
)
@api.model
def _format_markup_to_html(self, body_html):
"""
Convert WhatsApp format text to HTML format text
*bold* -> <b>bold</b>
_italic_ -> <i>italic</i>
~strikethrough~ -> <s>strikethrough</s>
```monospace``` -> <code>monospace</code>
"""
formatted_body = str(plaintext2html(body_html)) # stringify for regex
formatted_body = re.sub(r'\*(.*?)\*', r'<b>\1</b>', formatted_body)
formatted_body = re.sub(r'_(.*?)_', r'<i>\1</i>', formatted_body)
formatted_body = re.sub(r'~(.*?)~', r'<s>\1</s>', formatted_body)
formatted_body = re.sub(r'```(.*?)```', r'<code>\1</code>', formatted_body)
return Markup(formatted_body)
def _get_formatted_body(self, demo_fallback=False, variable_values=None):
"""Get formatted body and header with specified values.
:param bool demo_fallback: if true, fallback on demo values instead of blanks
:param dict variable_values: values to use instead of demo values {'header-{{1}}': 'Hello'}
:return Markup:
"""
self.ensure_one()
variable_values = variable_values or {}
header = ''
if self.header_type == 'text' and self.header_text:
header = self.header_text
header_variables = self.variable_ids.filtered(lambda line: line.line_type == 'header')
if header_variables:
fallback_value = header_variables[0].demo_value if demo_fallback else ' '
header = header.replace('{{1}}', variable_values.get('header-{{1}}', fallback_value))
body = self.body
for var in self.variable_ids.filtered(lambda var: var.line_type == 'body'):
fallback_value = var.demo_value if demo_fallback else ' '
body = body.replace(var.name, variable_values.get(f'{var.line_type}-{var.name}', fallback_value))
return self._format_markup_to_html(f'*{header}*\n\n{body}' if header else body)
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
@api.model
def _can_use_whatsapp(self, model_name):
if not self.has_access('read'):
return False
return self.env.user.has_group('whatsapp.group_whatsapp_admin') or \
len(self._find_default_for_model(model_name)) > 0
@api.model
def _find_default_for_model(self, model_name):
return self.search([
('model', '=', model_name),
('status', '=', 'approved'),
'|',
('allowed_user_ids', '=', False),
('allowed_user_ids', 'in', self.env.user.ids)
], limit=1)

View File

@ -0,0 +1,112 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from urllib.parse import urlparse
from odoo import api, fields, models, _
from odoo.addons.phone_validation.tools import phone_validation
from odoo.exceptions import UserError, ValidationError
class WhatsAppTemplateButton(models.Model):
_name = 'whatsapp.template.button'
_description = 'WhatsApp Template Button'
_order = 'sequence,id'
sequence = fields.Integer()
name = fields.Char(string="Button Text", size=25)
wa_template_id = fields.Many2one(comodel_name='whatsapp.template', required=True, ondelete='cascade')
button_type = fields.Selection([
('url', 'Visit Website'),
('phone_number', 'Call Number'),
('quick_reply', 'Quick Reply')], string="Type", required=True, default='quick_reply')
url_type = fields.Selection([
('static', 'Static'),
('dynamic', 'Dynamic')], string="Url Type", default='static')
website_url = fields.Char(string="Website URL")
call_number = fields.Char(string="Call Number")
has_invalid_number = fields.Boolean(compute="_compute_has_invalid_number")
variable_ids = fields.One2many(
'whatsapp.template.variable', 'button_id',
compute='_compute_variable_ids', precompute=True, store=True,
copy=True)
_sql_constraints = [
(
'unique_name_per_template',
'UNIQUE(name, wa_template_id)',
"Button names must be unique in a given template"
)
]
@api.depends('button_type', 'call_number')
def _compute_has_invalid_number(self):
for button in self:
if button.button_type == 'phone_number' and button.call_number:
try:
phone_validation.phone_format(
button.call_number,
False,
False,
)
except UserError:
if country := self.env.user.country_id or self.env.company.country_id:
try:
phone_validation.phone_format(
button.call_number,
country.code,
country.phone_code,
)
except UserError:
button.has_invalid_number = True
continue
button.has_invalid_number = False
def _get_button_variable_vals(self, button):
return {
"demo_value": button.website_url + "???",
"line_type": "button",
"name": button.name,
"wa_template_id": button.wa_template_id.id,
}
def _filter_dynamic_buttons(self):
"""
Retrieve buttons filtered by 'dynamic' URL type.
"""
dynamic_urls = self.filtered(lambda button: button.button_type == 'url' and button.url_type == 'dynamic')
return dynamic_urls
@api.depends('button_type', 'url_type', 'website_url', 'name')
def _compute_variable_ids(self):
button_urls = self._filter_dynamic_buttons()
to_clear = self - button_urls
for button in button_urls:
if button.variable_ids:
button.variable_ids = [
(1, button.variable_ids[0].id, self._get_button_variable_vals(button)),
]
else:
button.variable_ids = [
(0, 0, self._get_button_variable_vals(button)),
]
if to_clear:
to_clear.variable_ids = [(5, 0)]
def check_variable_ids(self):
for button in self:
if len(button.variable_ids) > 1:
raise ValidationError(_('Buttons may only contain one placeholder.'))
if button.variable_ids and button.url_type != 'dynamic':
raise ValidationError(_('Only dynamic urls may have a placeholder.'))
elif button.url_type == 'dynamic' and not button.variable_ids:
raise ValidationError(_('All dynamic urls must have a placeholder.'))
if button.variable_ids.name != "{{1}}":
raise ValidationError(_('The placeholder for a button can only be {{1}}.'))
@api.onchange('website_url')
def _onchange_website_url(self):
if self.website_url:
parsed_url = urlparse(self.website_url)
if not (parsed_url.scheme in {'http', 'https'} and parsed_url.netloc):
self.website_url = f"https://{self.website_url}"

View File

@ -0,0 +1,160 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.urls import url_join
from odoo import api, models, fields, _
from odoo.exceptions import UserError, ValidationError
class WhatsAppTemplateVariable(models.Model):
_name = 'whatsapp.template.variable'
_description = 'WhatsApp Template Variable'
_order = 'line_type desc, name, id'
name = fields.Char(string="Placeholder", required=True)
button_id = fields.Many2one('whatsapp.template.button', ondelete='cascade')
wa_template_id = fields.Many2one(comodel_name='whatsapp.template', required=True, ondelete='cascade')
model = fields.Char(string="Model Name", related='wa_template_id.model')
line_type = fields.Selection([
('button', 'Button'),
('header', 'Header'),
('location', 'Location'),
('body', 'Body')], string="Variable location", required=True)
field_type = fields.Selection([
('user_name', 'User Name'),
('user_mobile', 'User Mobile'),
('free_text', 'Free Text'),
('portal_url', 'Portal Link'),
('field', 'Field of Model')], string="Type", default='free_text', required=True)
field_name = fields.Char(string="Field")
demo_value = fields.Char(string="Sample Value", default="Sample Value", required=True)
_sql_constraints = [
(
'name_type_template_unique',
'UNIQUE(name, line_type, wa_template_id, button_id)',
'Variable names must be unique for a given template'
),
]
@api.constrains("field_type", "demo_value", "button_id")
def _check_demo_values(self):
if self.filtered(lambda var: var.field_type == 'free_text' and not var.demo_value):
raise ValidationError(_('Free Text template variables must have a demo value.'))
@api.constrains("field_type", "field_name")
def _check_field_name(self):
is_system = self.env.user.has_group('base.group_system')
failing = self.browse()
to_check = self.filtered(lambda v: v.field_type == "field")
missing = to_check.filtered(lambda v: not v.field_name)
if missing:
raise ValidationError(
_("Field template variables %(var_names)s must be associated with a field.",
var_names=", ".join(missing.mapped("name")),
)
)
for variable in to_check:
model = self.env[variable.model]
if not is_system:
if not model.has_access('read'):
model_description = self.env['ir.model']._get(variable.model).display_name
raise ValidationError(
_("You can not select field of %(model)s.", model=model_description)
)
safe_fields = model._get_whatsapp_safe_fields() if hasattr(model, '_get_whatsapp_safe_fields') else []
if variable.field_name not in safe_fields:
raise ValidationError(
_("You are not allowed to use field %(field)s, contact your administrator.",
field=variable.field_name)
)
try:
model._find_value_from_field_path(variable.field_name)
except UserError:
failing += variable
if failing:
model_description = self.env['ir.model']._get(failing.mapped('model')[0]).display_name
raise ValidationError(
_("Variables %(field_names)s do not seem to be valid field path for model %(model_name)s.",
field_names=", ".join(failing.mapped("field_name")),
model_name=model_description,
)
)
@api.constrains('name')
def _check_name(self):
for variable in self:
if variable.line_type == 'location' and variable.name not in {'name', 'address', 'latitude', 'longitude'}:
raise ValidationError(
_("Location variable should be 'name', 'address', 'latitude' or 'longitude'. Cannot parse '%(placeholder)s'",
placeholder=variable.name))
elif variable.line_type == 'button' and variable.name != variable.button_id.name:
raise ValidationError(_("Dynamic button variable name must be the same as its respective button's name"))
elif variable.line_type in ('header', 'body') and not variable._extract_variable_index():
raise ValidationError(
_('Template variable should be in format {{number}}. Cannot parse "%(placeholder)s"',
placeholder=variable.name))
@api.constrains('button_id', 'line_type')
def _check_button_id(self):
for variable in self:
if variable.line_type == 'button' and not variable.button_id:
raise ValidationError(_('Button variables must be linked to a button.'))
@api.depends('line_type', 'name')
def _compute_display_name(self):
type_names = dict(self._fields["line_type"]._description_selection(self.env))
for variable in self:
type_name = type_names[variable.line_type or 'body']
variable.display_name = type_name if variable.line_type == 'header' else f'{type_name} - {variable.name}'
@api.onchange('model')
def _onchange_model_id(self):
self.field_name = False
@api.onchange('field_type')
def _onchange_field_type(self):
if self.field_type != 'field':
self.field_name = False
def _get_variables_value(self, record):
value_by_name = {}
user = self.env.user
for variable in self:
if variable.field_type == 'user_name':
value = user.name
elif variable.field_type == 'user_mobile':
value = user.mobile
elif variable.field_type == 'field':
value = variable._find_value_from_field_chain(record)
elif variable.field_type == 'portal_url':
portal_url = record._whatsapp_get_portal_url()
value = url_join(variable.get_base_url(), (portal_url or ''))
else:
value = variable.demo_value
value_str = value and str(value) or ''
if variable.button_id:
value_by_name[f"button-{variable.button_id.name}"] = value_str
else:
value_by_name[f"{variable.line_type}-{variable.name}"] = value_str
return value_by_name
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
def _find_value_from_field_chain(self, record):
"""Get the value of field, returning display_name(s) if the field is a model."""
self.ensure_one()
return record.sudo(False)._find_value_from_field_path(self.field_name)
def _extract_variable_index(self):
""" Extract variable index, located between '{{}}' markers. """
self.ensure_one()
try:
return int(self.name.lstrip('{{').rstrip('}}'))
except ValueError:
return None

View File

@ -0,0 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import whatsapp_account
from . import whatsapp_template
from . import whatsapp_message
from . import discuss_channel
from . import mail_message

View File

@ -0,0 +1,46 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import random
import string
from odoo import models
_logger = logging.getLogger(__name__)
class DiscussChannel(models.Model):
_inherit = "discuss.channel"
@property
def _populate_dependencies(self):
return super()._populate_dependencies + ["whatsapp.account"]
def _populate(self, size):
res = super()._populate(size)
accounts = self.env["whatsapp.account"].browse(self.env.registry.populated_models["whatsapp.account"])
group = self.env.ref("base.group_system")
partners = self.env["res.partner"].browse(self.env.registry.populated_models["res.partner"])
partners = partners.filtered(lambda partner: not partner.is_company)
def generate_random_phone(**kwargs):
return '+91' + ''.join(random.choices(string.digits[1:], k=10))
channels = []
for _ in range(0, {"small": 30, "medium": 200, "large": 1000}[size]):
whatsapp_number = generate_random_phone()
partner = random.choice(partners)
channels.append(
{
"channel_partner_ids": [(4, partner.id)],
"channel_type": "whatsapp",
"group_ids": group,
"name": f"{whatsapp_number} {partner.name}",
'wa_account_id': random.choice(accounts.ids),
'whatsapp_number': whatsapp_number,
"whatsapp_partner_id": partner.id,
}
)
# install_mode to prevent from automatically adding system as member
res += self.env["discuss.channel"].with_context(install_mode=True).create(channels)
return res

View File

@ -0,0 +1,43 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import models
from odoo.tools import populate
_logger = logging.getLogger(__name__)
class Message(models.Model):
_inherit = "mail.message"
def _populate(self, size):
res = super()._populate(size)
random = populate.Random("mail.message in WhatsApp")
channels = self.env["discuss.channel"].browse(self.env.registry.populated_models["discuss.channel"])
messages = []
big_done = 0
for channel in channels.filtered(lambda channel: channel.channel_type == "whatsapp"):
big = {"small": 80, "medium": 150, "large": 300}[size]
small_big_ratio = {"small": 10, "medium": 150, "large": 1000}[size]
max_messages = big if random.randint(1, small_big_ratio) == 1 else 60
number_messages = 200 if big_done < 2 else random.randrange(max_messages)
if number_messages >= 200:
big_done += 1
for counter in range(number_messages):
messages.append(
{
"author_id": random.choice(channel.channel_member_ids.partner_id).id,
"body": f"whatsapp_message_body_{counter}",
"message_type": "whatsapp_message",
"model": "discuss.channel",
"res_id": channel.id,
}
)
batches = [messages[i : i + 1000] for i in range(0, len(messages), 1000)]
count = 0
for batch in batches:
count += len(batch)
_logger.info("Batch of mail.message for discuss.channel(whatsapp): %s/%s", count, len(messages))
res += self.env["mail.message"].create(batch)
return res

View File

@ -0,0 +1,45 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import string
from odoo import models
from odoo.tools import populate
class WhatsappAccount(models.Model):
_inherit = "whatsapp.account"
_populate_dependencies = ["res.users"]
_populate_sizes = {'small': 2, 'medium': 5, 'large': 10}
def _populate_factories(self):
random = populate.Random("whatsapp.account")
users = self.env.registry.populated_models["res.users"]
def generate_random_app_id_secret(**kwargs):
return ''.join(random.choices(string.ascii_uppercase, k=15))
def generate_random_account_uid(**kwargs):
return ''.join(random.choices(string.ascii_uppercase, k=10))
def generate_random_phone_uid(**kwargs):
return ''.join(random.choices(string.digits[1:], k=10))
def generate_random_access_token(**kwargs):
return ''.join(random.choices(string.ascii_uppercase, k=20))
def get_notify_user_ids(**kwargs):
return [
(6, 0, [
random.choice(users) for i in range(random.randint(1, len(users)))
])
]
return [
('name', populate.constant("WA-ac-{counter}")),
('app_uid', populate.compute(generate_random_app_id_secret)),
('app_secret', populate.compute(generate_random_app_id_secret)),
('account_uid', populate.compute(generate_random_account_uid)),
('phone_uid', populate.compute(generate_random_phone_uid)),
('token', populate.compute(generate_random_access_token)),
('notify_user_ids', populate.compute(get_notify_user_ids)),
]

View File

@ -0,0 +1,44 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import string
from odoo import models
from odoo.tools import populate
_logger = logging.getLogger(__name__)
class WhatsappMessage(models.Model):
_inherit = "whatsapp.message"
_populate_dependencies = ["whatsapp.template", "mail.message"]
_populate_sizes = {'small': 100, 'medium': 1_500, 'large': 25_000}
def _populate_factories(self):
random = populate.Random("whatsapp.message")
templates = self.env["whatsapp.template"].browse(self.env.registry.populated_models["whatsapp.template"])
templates = templates.filtered(lambda template: template.status == 'approved')
accounts = self.env.registry.populated_models["whatsapp.account"]
messages = self.env.registry.populated_models["mail.message"]
message_type = ['outbound', 'inbound']
state = ['outgoing', 'sent', 'delivered', 'read', 'received', 'error', 'cancel']
def compute_mobile_number(**kwargs):
return ''.join(random.choices(string.digits[1:], k=10))
def generate_random_message_uid(**kwargs):
return ''.join(random.choices(string.ascii_uppercase, k=10))
def compute_wa_template_id(**kwargs):
return random.choice(templates.ids)
return [
('mobile_number', populate.compute(compute_mobile_number)),
('message_type', populate.randomize(message_type)),
('state', populate.randomize(state)),
('wa_template_id', populate.compute(compute_wa_template_id)),
('msg_uid', populate.compute(generate_random_message_uid)),
('wa_account_id', populate.randomize(accounts)),
('mail_message_id', populate.randomize(messages)),
]

View File

@ -0,0 +1,53 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.tools import populate
from odoo.addons.whatsapp.tools.lang_list import Languages
class WhatsappTemplate(models.Model):
_inherit = "whatsapp.template"
_populate_dependencies = ["whatsapp.account"]
_populate_sizes = {'small': 10, 'medium': 100, 'large': 1000}
def _populate_factories(self):
random = populate.Random("whatsapp.template")
accounts = self.env["whatsapp.account"].browse(self.env.registry.populated_models["whatsapp.account"])
template_type = ['authentication', 'marketing', 'utility']
header_type = ['none', 'text']
status = [
'approved', 'draft', 'pending', 'in_appeal', 'paused',
'disabled', 'rejected', 'pending_deletion', 'deleted', 'limit_exceeded'
]
body = [
"Welcome to Odoo!\nWe're excited to have you.\nLet's achieve great things together.",
"Greetings from Odoo!\nYour journey with us starts now.\nLet's make it memorable.",
"Hello and welcome to Odoo!\nWe're here to support you.\nTogether, we'll reach new heights.",
"Odoo welcomes you!\nJoin our community of innovators.\nLet's create something amazing.",
"Glad to see you at Odoo!\nYour success is our priority.\nLet's work together for greatness.",
"Welcome aboard Odoo!\nWe're thrilled to have you here.\nLet's embark on this journey together.",
"Odoo is happy to have you!\nWe believe in your potential.\nLet's unlock it together.",
"A warm welcome to Odoo!\nYou are now part of our family.\nLet's grow and succeed together.",
"You are welcome at Odoo!\nWe value your presence.\nLet's achieve excellence together.",
"Odoo greets you warmly!\nWe look forward to working with you.\nLet's make great things happen."
]
def compute_lang_code(**kwargs):
return Languages[random.randint(0, len(Languages) - 1)][0]
def compute_wa_account_id(**kwargs):
return random.choice(accounts.ids)
return [
('name', populate.constant("WA-T-{counter}")),
('template_name', populate.constant("Template-{counter}")),
('lang_code', populate.compute(compute_lang_code)),
('template_type', populate.randomize(template_type)),
('wa_account_id', populate.compute(compute_wa_account_id)),
('header_type', populate.randomize(header_type)),
('footer_text', populate.constant("Write 'stop' to stop receiving messages")),
('header_text', populate.constant("Welcome to Odoo!")),
('status', populate.randomize(status, [55, 5, 5, 5, 5, 5, 5, 5, 5, 5])),
('body', populate.iterate(body)),
]

View File

@ -0,0 +1,15 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ir_model_wa_admin,access.ir.model.wa.admin,base.model_ir_model,group_whatsapp_admin,1,0,0,0
access_whatsapp_account_user,access.whatsapp.account.user,model_whatsapp_account,base.group_user,1,0,0,0
access_whatsapp_account_system_admin,access.whatsapp.account.system.admin,model_whatsapp_account,base.group_system,1,1,1,1
access_whatsapp_account_administrator,access.whatsapp.account.admin,model_whatsapp_account,group_whatsapp_admin,1,1,1,0
access_whatsapp_composer_user,access.whatsapp.composer,model_whatsapp_composer,base.group_user,1,1,1,1
access_whatsapp_message_administrator,access.whatsapp.message,model_whatsapp_message,group_whatsapp_admin,1,1,1,1
access_whatsapp_message_user,access.whatsapp.message,model_whatsapp_message,base.group_user,1,1,1,0
access_whatsapp_preview_user,access.whatsapp.preview,model_whatsapp_preview,base.group_user,1,1,1,1
access_whatsapp_template_administrator,access.whatsapp.template,model_whatsapp_template,group_whatsapp_admin,1,1,1,1
access_whatsapp_template_user,access.whatsapp.template,model_whatsapp_template,base.group_user,1,0,0,0
access_whatsapp_template_button_administrator,access.whatsapp.template.button,model_whatsapp_template_button,group_whatsapp_admin,1,1,1,1
access_whatsapp_template_button_user,access.whatsapp.template.button,model_whatsapp_template_button,base.group_user,1,0,0,0
access_whatsapp_template_variable_administrator,access.whatsapp.template.variable,model_whatsapp_template_variable,group_whatsapp_admin,1,1,1,1
access_whatsapp_template_variable_user,access.whatsapp.template.variable,model_whatsapp_template_variable,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ir_model_wa_admin access.ir.model.wa.admin base.model_ir_model group_whatsapp_admin 1 0 0 0
3 access_whatsapp_account_user access.whatsapp.account.user model_whatsapp_account base.group_user 1 0 0 0
4 access_whatsapp_account_system_admin access.whatsapp.account.system.admin model_whatsapp_account base.group_system 1 1 1 1
5 access_whatsapp_account_administrator access.whatsapp.account.admin model_whatsapp_account group_whatsapp_admin 1 1 1 0
6 access_whatsapp_composer_user access.whatsapp.composer model_whatsapp_composer base.group_user 1 1 1 1
7 access_whatsapp_message_administrator access.whatsapp.message model_whatsapp_message group_whatsapp_admin 1 1 1 1
8 access_whatsapp_message_user access.whatsapp.message model_whatsapp_message base.group_user 1 1 1 0
9 access_whatsapp_preview_user access.whatsapp.preview model_whatsapp_preview base.group_user 1 1 1 1
10 access_whatsapp_template_administrator access.whatsapp.template model_whatsapp_template group_whatsapp_admin 1 1 1 1
11 access_whatsapp_template_user access.whatsapp.template model_whatsapp_template base.group_user 1 0 0 0
12 access_whatsapp_template_button_administrator access.whatsapp.template.button model_whatsapp_template_button group_whatsapp_admin 1 1 1 1
13 access_whatsapp_template_button_user access.whatsapp.template.button model_whatsapp_template_button base.group_user 1 0 0 0
14 access_whatsapp_template_variable_administrator access.whatsapp.template.variable model_whatsapp_template_variable group_whatsapp_admin 1 1 1 1
15 access_whatsapp_template_variable_user access.whatsapp.template.variable model_whatsapp_template_variable base.group_user 1 0 0 0

View File

@ -0,0 +1,89 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo noupdate="1">
<record id="security_rule_whatsapp_account" model="ir.rule">
<field name="name">WA Account: Restrict to Allowed Companies</field>
<field name="model_id" ref="model_whatsapp_account"/>
<field name="domain_force">[('allowed_company_ids', 'in', company_ids)]</field>
</record>
<record id="security_rule_whatsapp_composer" model="ir.rule">
<field name="name">WA Composer: Restrict to Own</field>
<field name="model_id" ref="model_whatsapp_composer"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
<record id="security_rule_whatsapp_message_user" model="ir.rule">
<field name="name">WA Message: Restrict to Own</field>
<field name="model_id" ref="model_whatsapp_message"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="security_rule_whatsapp_message_admin" model="ir.rule">
<field name="name">WA Message: Un-restrict for WA Admins</field>
<field name="model_id" ref="model_whatsapp_message"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
</record>
<record id="security_rule_whatsapp_preview" model="ir.rule">
<field name="name">WA Preview: Restrict to Own</field>
<field name="model_id" ref="model_whatsapp_preview"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
<record id="security_rule_whatsapp_template" model="ir.rule">
<field name="name">WA Template: Restrict to Allowed Companies</field>
<field name="model_id" ref="model_whatsapp_template"/>
<field name="domain_force">['|', ('wa_account_id', '=', False), ('wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
</record>
<record id="security_rule_whatsapp_template_user" model="ir.rule">
<field name="name">WA Template: Restrict to Allowed Users</field>
<field name="model_id" ref="model_whatsapp_template"/>
<field name="domain_force">['|', ('allowed_user_ids', '=', False), ('allowed_user_ids', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="security_rule_whatsapp_template_admin" model="ir.rule">
<field name="name">WA Template: Un-restrict for WA Admins</field>
<field name="model_id" ref="model_whatsapp_template"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
</record>
<record id="security_rule_whatsapp_template_button" model="ir.rule">
<field name="name">WA Template Button: Restrict to Allowed Companies</field>
<field name="model_id" ref="model_whatsapp_template_button"/>
<field name="domain_force">['|', ('wa_template_id.wa_account_id', '=', False), ('wa_template_id.wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
</record>
<record id="security_rule_whatsapp_template_button_user" model="ir.rule">
<field name="name">WA Template Button: Restrict to Allowed Users</field>
<field name="model_id" ref="model_whatsapp_template_button"/>
<field name="domain_force">['|', ('wa_template_id.allowed_user_ids', '=', False), ('wa_template_id.allowed_user_ids', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="security_rule_whatsapp_template_button_admin" model="ir.rule">
<field name="name">WA Template Button: Un-restrict for WA Admins</field>
<field name="model_id" ref="model_whatsapp_template_button"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
</record>
<record id="security_rule_whatsapp_template_variable" model="ir.rule">
<field name="name">WA Template Variable: Restrict to Allowed Companies</field>
<field name="model_id" ref="model_whatsapp_template_variable"/>
<field name="domain_force">['|', ('wa_template_id.wa_account_id', '=', False), ('wa_template_id.wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
</record>
<record id="security_rule_whatsapp_template_variable_user" model="ir.rule">
<field name="name">WA Template Variable: Restrict to Allowed Users</field>
<field name="model_id" ref="model_whatsapp_template_variable"/>
<field name="domain_force">['|', ('wa_template_id.allowed_user_ids', '=', False), ('wa_template_id.allowed_user_ids', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="security_rule_whatsapp_template_variable_admin" model="ir.rule">
<field name="name">WA Template Variable: Un-restrict for WA Admins</field>
<field name="model_id" ref="model_whatsapp_template_variable"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
</record>
</odoo>

View File

@ -0,0 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<record id="group_whatsapp_admin" model="res.groups">
<field name="name">Administrator</field>
<field name="category_id" ref="base.module_category_marketing_whatsapp"/>
<field name="users" eval="[Command.link(ref('base.user_admin'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
<style type="text/css">
.st0{fill:#B3B3B3;}
.st1{fill:#FFFFFF;}
.st2{fill:none;}
.st3{fill:url(#SVGID_1_);}
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
</style>
<path class="st0" d="M14.5,40.4l0.7,0.4c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5.1-2-9.8-5.5-13.4
c-3.5-3.6-8.4-5.6-13.4-5.6c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1l0.5,0.7l-1.9,7L14.5,40.4z M1.9,47.7l3.2-11.8
c-2-3.5-3-7.4-3-11.4C2.1,12,12.3,1.8,24.8,1.8c6.1,0,11.8,2.4,16.1,6.7s6.7,10,6.7,16.1c0,12.6-10.2,22.8-22.8,22.8h0
c-3.8,0-7.6-1-10.9-2.8L1.9,47.7z"/>
<path class="st1" d="M1.6,47.5l3.2-11.8c-2-3.5-3-7.4-3-11.4C1.8,11.7,12,1.5,24.6,1.5c6.1,0,11.8,2.4,16.1,6.7s6.7,10,6.7,16.1
c0,12.6-10.2,22.8-22.8,22.8h0c-3.8,0-7.6-1-10.9-2.8L1.6,47.5z"/>
<path class="st2" d="M24.6,5.4c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1L9,35.1l-1.9,7l7.2-1.9l0.7,0.4
c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5-2-9.8-5.5-13.4C34.5,7.4,29.6,5.4,24.6,5.4L24.6,5.4z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="24.5016" y1="44.8036" x2="24.6936" y2="12.4423" gradientTransform="matrix(1 0 0 -1 0 52.448)">
<stop offset="0" style="stop-color:#57D163"/>
<stop offset="1" style="stop-color:#23B33A"/>
</linearGradient>
<path class="st3" d="M24.6,5.4c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1L9,35.1l-1.9,7l7.2-1.9l0.7,0.4
c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5-2-9.8-5.5-13.4C34.5,7.4,29.6,5.4,24.6,5.4z"/>
<path class="st4" d="M18.9,14.8c-0.4-0.9-0.9-1-1.3-1l-1.1,0c-0.4,0-1,0.1-1.5,0.7c-0.5,0.6-2,1.9-2,4.7s2,5.5,2.3,5.9
c0.3,0.4,3.9,6.3,9.7,8.6c4.8,1.9,5.8,1.5,6.8,1.4c1-0.1,3.4-1.4,3.8-2.7c0.5-1.3,0.5-2.5,0.3-2.7c-0.1-0.2-0.5-0.4-1.1-0.7
c-0.6-0.3-3.4-1.7-3.9-1.9c-0.5-0.2-0.9-0.3-1.3,0.3c-0.4,0.6-1.5,1.9-1.8,2.2c-0.3,0.4-0.7,0.4-1.2,0.1c-0.6-0.3-2.4-0.9-4.6-2.8
c-1.7-1.5-2.8-3.4-3.2-3.9s0-0.9,0.3-1.2c0.3-0.3,0.6-0.7,0.9-1c0.3-0.3,0.4-0.6,0.6-0.9c0.2-0.4,0.1-0.7,0-1
C20.5,18.7,19.4,15.9,18.9,14.8"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

View File

@ -0,0 +1,39 @@
/** @odoo-module */
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
patch(Chatter.prototype, {
sendWhatsapp() {
const send = async (thread) => {
await new Promise((resolve) => {
this.env.services.action.doAction(
{
type: "ir.actions.act_window",
name: _t("Send WhatsApp Message"),
res_model: "whatsapp.composer",
view_mode: "form",
views: [[false, "form"]],
target: "new",
context: {
active_model: thread.model,
active_id: thread.id,
},
},
{ onClose: resolve }
);
});
this.store.Thread.insert({
model: this.props.threadModel,
id: this.props.threadId,
}).fetchNewMessages();
};
if (this.state.thread.id) {
send(this.state.thread);
} else {
this.onThreadCreated = send;
this.props.saveRecord?.();
}
},
});

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="whatsapp.Chatter" t-inherit="mail.Chatter" t-inherit-mode="extension">
<xpath expr="//*[contains(@class, 'o-mail-Chatter-activity')]" position="before">
<button t-if="state.thread.canSendWhatsapp"
class="btn btn-secondary text-nowrap me-1"
t-att-class="{'my-2': !props.compactHeight }"
data-hotkey="shift+w"
t-on-click="sendWhatsapp"
>
<span>WhatsApp</span>
</button>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,41 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
import { PhoneField, phoneField, formPhoneField } from "@web/views/fields/phone/phone_field";
import { SendWhatsAppButton } from "../whatsapp_button/whatsapp_button.js";
patch(PhoneField, {
components: {
...PhoneField.components,
SendWhatsAppButton,
},
defaultProps: {
...PhoneField.defaultProps,
enableWhatsAppButton: true,
},
props: {
...PhoneField.props,
enableWhatsAppButton: { type: Boolean, optional: true },
},
});
const patchDescr = {
extractProps({ options }) {
const props = super.extractProps(...arguments);
props.enableWhatsAppButton = options.enable_whatsapp;
return props;
},
supportedOptions: [
...(phoneField.supportedOptions ? phoneField.supportedOptions : []),
{
label: _t("Enable WhatsApp"),
name: "enable_whatsapp",
type: "boolean",
default: true,
},
],
};
patch(phoneField, patchDescr);
patch(formPhoneField, patchDescr);

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="whatsapp.PhoneField" t-inherit="web.PhoneField" t-inherit-mode="extension">
<xpath expr="//div[contains(@class, 'o_phone_content')]//a" position="after">
<t t-if="props.enableWhatsAppButton and props.record.data[props.name].length > 0">
<SendWhatsAppButton t-props="props" />
</t>
</xpath>
</t>
<t t-name="whatsapp.FormPhoneField" t-inherit="web.FormPhoneField" t-inherit-mode="extension">
<xpath expr="//div[contains(@class, 'o_phone_content')]" position="inside">
<t t-if="props.enableWhatsAppButton and props.record.data[props.name].length > 0">
<SendWhatsAppButton t-props="props" />
</t>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,42 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
export class SendWhatsAppButton extends Component {
static template = "whatsapp.SendWhatsAppButton";
static props = ["*"];
setup() {
this.action = useService("action");
this.title = _t("Send WhatsApp Message");
}
async onClick() {
await this.props.record.save();
this.action.doAction(
{
type: "ir.actions.act_window",
target: "new",
name: this.title,
res_model: "whatsapp.composer",
views: [[false, "form"]],
context: {
...user.context,
active_model: this.props.record.resModel,
active_id: this.props.record.resId,
default_phone: this.props.record.data[this.props.name],
},
},
{
onClose: () => {
this.props.record.load();
this.props.record.model.notify();
},
}
);
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="whatsapp.SendWhatsAppButton">
<a
t-att-title="title"
t-att-href="'whatsapp:' + props.record.data[props.name]"
t-on-click.prevent.stop="onClick"
class="ms-3 d-inline-flex align-items-center o_field_phone_whatsapp"
>
<i class="fa fa-whatsapp"/>
<small class="fw-bold ms-1">WhatsApp</small>
</a>
</t>
</templates>

View File

@ -0,0 +1,57 @@
/* @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { TextField, textField } from "@web/views/fields/text/text_field";
import { Tooltip } from "@web/core/tooltip/tooltip";
import { usePopover } from "@web/core/popover/popover_hook";
import { useRef } from "@odoo/owl";
export class WhatsappVariablesTextField extends TextField {
static template = "whatsapp.WhatsappVariablesTextField";
static components = { ...TextField.components };
setup() {
super.setup();
this.textareaRef = useRef('textarea');
this.variablesButton = useRef('variablesButton');
this.popover = usePopover(Tooltip, { animation: false, position: "left" });
}
_onClickAddVariables() {
const originalContent = this.textareaRef.el.value;
const start = this.textareaRef.el.selectionStart;
const end = this.textareaRef.el.selectionEnd;
const matches = Array.from(originalContent.matchAll(/{{(\d+)}}/g));
const integerList = matches.map(match => parseInt(match[1]));
const nextVariable = Math.max(...integerList, 0) + 1;
if (nextVariable > 10){
// Show tooltip
this.popover.open(this.variablesButton.el, { tooltip: _t("You can set a maximum of 10 variables.") });
browser.setTimeout(this.popover.close, 2600);
return;
}
const separator = originalContent.slice(0, start) ? ' ' : '';
this.textareaRef.el.value = originalContent.slice(0, start) + separator + '{{' + nextVariable + '}}' + originalContent.slice(end, originalContent.length);
// Trigger onInput from input_field hook to set field as dirty
this.textareaRef.el.dispatchEvent(new InputEvent("input"));
// Keydown on "enter" serves to both commit the changes in input_field and trigger onchange for some fields
this.textareaRef.el.dispatchEvent(new KeyboardEvent("keydown", {key: 'Enter'}));
this.textareaRef.el.focus();
const newCursorPos = start + separator.length + nextVariable.toString().length + 4; // 4 is the number of brackets {{ }}
this.textareaRef.el.setSelectionRange(newCursorPos, newCursorPos);
}
}
export const whatsappVariablesTextField = {
...textField,
component: WhatsappVariablesTextField,
additionalClasses: [...(textField.additionalClasses || []), "o_field_text"],
};
registry.category("fields").add("whatsapp_text_variables", whatsappVariablesTextField);

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="whatsapp.WhatsappVariablesTextField" t-inherit="web.TextField" t-inherit-mode="primary">
<xpath expr="//textarea" position="after">
<div class="position-relative d-inline">
<button class="btn position-absolute end-0 text-primary py-0" t-ref="variablesButton" t-on-click="_onClickAddVariables">
<div class="d-flex align-items-center gap-1 lh-1">
<i class="fa fa-plus" role="img" title="Insert variable"/>
<span>variable</span>
</div>
</button>
</div>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,5 @@
declare module "models" {
export interface Message {
whatsappStatus: string,
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="whatsapp.ChatWindow.headerContent" t-inherit="mail.ChatWindow.headerContent" t-inherit-mode="extension">
<xpath expr="//ThreadIcon" position="after">
<ThreadIcon t-elif="thread and thread.channel_type === 'whatsapp' and thread.correspondent" thread="thread"/>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,7 @@
/** @odoo-module */
import { Composer } from "@mail/core/common/composer_model";
Object.assign(Composer.prototype, "whatsapp_composer_model", {
threadExpired: false,
});

View File

@ -0,0 +1,95 @@
/** @odoo-module */
import { Composer } from "@mail/core/common/composer";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
import { onWillDestroy, useEffect } from "@odoo/owl";
patch(Composer.prototype, {
setup() {
super.setup();
this.composerDisableCheckTimeout = null;
useEffect(
() => {
clearTimeout(this.composerDisableCheckTimeout);
this.checkComposerDisabled();
},
() => [this.thread?.whatsapp_channel_valid_until]
);
onWillDestroy(() => clearTimeout(this.composerDisableCheckTimeout));
},
get placeholder() {
if (
this.thread &&
this.thread.channel_type === "whatsapp" &&
!this.state.active &&
this.props.composer.threadExpired
) {
return _t(
"Can't send message as it has been 24 hours since the last message of the User."
);
}
return super.placeholder;
},
checkComposerDisabled() {
if (this.thread && this.thread.channel_type === "whatsapp") {
const datetime = this.thread.whatsappChannelValidUntilDatetime;
if (!datetime) {
return;
}
const delta = datetime.ts - Date.now();
if (delta <= 0) {
this.state.active = false;
this.props.composer.threadExpired = true;
} else {
this.state.active = true;
this.props.composer.threadExpired = false;
this.composerDisableCheckTimeout = setTimeout(() => {
this.checkComposerDisabled();
}, delta);
}
}
},
get hasSendButtonNonEditing() {
if (this.thread?.channel_type === "whatsapp" && !this.state.active) {
return false;
}
return super.hasSendButtonNonEditing;
},
/** @override */
get isSendButtonDisabled() {
const whatsappInactive =
this.thread && this.thread.channel_type === "whatsapp" && !this.state.active;
return super.isSendButtonDisabled || whatsappInactive;
},
onDropFile(ev) {
this.processFileUploading(ev, super.onDropFile.bind(this));
},
onPaste(ev) {
if (ev.clipboardData.files.length === 0) {
return super.onPaste(ev);
}
this.processFileUploading(ev, super.onPaste.bind(this));
},
processFileUploading(ev, superCb) {
if (
this.thread?.channel_type === "whatsapp" &&
this.props.composer.attachments.length > 0
) {
ev.preventDefault();
this.env.services.notification.add(
_t("Only one attachment is allowed for each message"),
{ type: "warning" }
);
return;
}
superCb(ev);
},
});

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="whatsapp.Composer.actions" t-inherit="mail.Composer.actions" t-inherit-mode="extension">
<xpath expr="//*[@name='root']" position="replace">
<t t-if="thread?.channel_type === 'whatsapp' and !state.active"></t>
<t t-else="">$0</t>
</xpath>
</t>
<t t-name="whatsapp.Composer.quickActions" t-inherit="mail.Composer.quickActions" t-inherit-mode="extension">
<xpath expr="//*[@name='root']" position="replace">
<t t-if="thread?.channel_type === 'whatsapp' and !state.active"></t>
<t t-else="">$0</t>
</xpath>
</t>
<t t-name="whatsapp.Composer.extraActions" t-inherit="mail.Composer.extraActions" t-inherit-mode="extension">
<xpath expr="//*[@name='root']" position="replace">
<t t-if="thread?.channel_type === 'whatsapp' and !state.active"></t>
<t t-else="">$0</t>
</xpath>
</t>
<t t-name="whatsapp.Composer.attachFiles" t-inherit="mail.Composer.attachFiles" t-inherit-mode="extension">
<xpath expr="//FileUploader" position="attributes">
<attribute name="multiUpload">thread and thread.channel_type === 'whatsapp' ? false : true</attribute>
</xpath>
<xpath expr="//FileUploader/t/button" position="attributes">
<attribute name="t-att-disabled" add="or (thread and thread.channel_type === 'whatsapp' and props.composer.attachments.length > 0)" separator=" " />
</xpath>
</t>
</templates>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="whatsapp.ImStatus" t-inherit="mail.ImStatus" t-inherit-mode="extension">
<xpath expr="//i[@title='Bot']" position="after">
<i t-elif="props.member?.eq(props.member.thread.whatsappMember)"
class="fa fa-whatsapp text-success" title="WhatsApp User"
role="img" aria-label="WhatsApp User" />
</xpath>
</t>
</templates>

View File

@ -0,0 +1,17 @@
/** @odoo-module */
import { Message } from "@mail/core/common/message_model";
import { patch } from "@web/core/utils/patch";
patch(Message.prototype, {
get editable() {
if (this.thread?.channel_type === "whatsapp") {
return false;
}
return super.editable;
},
/** @override */
canReplyTo(thread) {
return super.canReplyTo(thread) && !this.thread?.composer?.threadExpired;
},
});

View File

@ -0,0 +1,27 @@
import { Message } from "@mail/core/common/message";
import { patch } from "@web/core/utils/patch";
patch(Message.prototype, {
get showSeenIndicator() {
return super.showSeenIndicator && this.message.whatsappStatus !== "error";
},
/**
* @param {MouseEvent} ev
*/
async onClick(ev) {
const id = Number(ev.target.dataset.oeId);
if (ev.target.closest(".o_whatsapp_channel_redirect")) {
ev.preventDefault();
let thread = await this.store.Thread.getOrFetch({ model: "discuss.channel", id });
if (!thread?.hasSelfAsMember) {
await this.env.services.orm.call("discuss.channel", "add_members", [[id]], {
partner_ids: [this.store.self.id],
});
thread = await this.store.Thread.getOrFetch({ model: "discuss.channel", id });
}
thread.open();
return;
}
super.onClick(ev);
},
});

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="whatsapp.Message" t-inherit="mail.Message" t-inherit-mode="extension">
<xpath expr="//div[contains(@class, 'o-mail-Message-header')]" position="inside">
<t t-if="message.whatsappStatus === 'error' or message.message_type === 'whatsapp_message'">
<span class="fa fa-whatsapp ms-1" t-att-class="message.whatsappStatus === 'error' ? 'text-danger' : 'text-success'"
t-att-title="message.whatsappStatus === 'error' ? 'Failed to send': 'Sent On WhatsApp'" />
</t>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,36 @@
import { Store } from "@mail/core/common/store_service";
import { patch } from "@web/core/utils/patch";
patch(Store.prototype, {
async getMessagePostParams({ thread }) {
const params = await super.getMessagePostParams(...arguments);
if (thread.channel_type === "whatsapp") {
params.post_data.message_type = "whatsapp_message";
}
return params;
},
async openWhatsAppChannel(id, name) {
const thread = this.Thread.insert({
channel_type: "whatsapp",
id,
model: "discuss.channel",
name,
});
if (!thread.avatarCacheKey) {
thread.avatarCacheKey = "hello";
}
if (!thread.hasSelfAsMember) {
const data = await this.env.services.orm.call(
"discuss.channel",
"whatsapp_channel_join_and_pin",
[[id]]
);
this.insert(data);
} else if (!thread.is_pinned) {
thread.pin();
}
thread.open();
},
});

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="whatsapp.ThreadIcon" t-inherit="mail.ThreadIcon" t-inherit-mode="extension">
<xpath expr="//*[contains(@class, 'o-mail-ThreadIcon')]" position="inside">
<t t-if="props.thread.channel_type === 'whatsapp'">
<t name="whatsapp">
<t name="whatsapp_static">
<div class="fa fa-whatsapp text-success" title="WhatsApp" />
</t>
</t>
</t>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,43 @@
/* @odoo-module */
import { Thread } from "@mail/core/common/thread_model";
import { patch } from "@web/core/utils/patch";
import { deserializeDateTime } from "@web/core/l10n/dates";
import { toRaw } from "@odoo/owl";
patch(Thread.prototype, {
get importantCounter() {
if (this.channel_type === "whatsapp") {
return this.selfMember?.message_unread_counter || this.message_needaction_counter;
}
return super.importantCounter;
},
get canLeave() {
return this.channel_type !== "whatsapp" && super.canLeave;
},
get canUnpin() {
if (this.channel_type === "whatsapp") {
return this.importantCounter === 0;
}
return super.canUnpin;
},
get avatarUrl() {
if (this.channel_type === "whatsapp" && this.correspondent) {
return this.correspondent.persona.avatarUrl;
}
return super.avatarUrl;
},
get isChatChannel() {
return this.channel_type === "whatsapp" || super.isChatChannel;
},
get whatsappChannelValidUntilDatetime() {
if (!this.whatsapp_channel_valid_until) {
return undefined;
}
return toRaw(deserializeDateTime(this.whatsapp_channel_valid_until));
},
});

View File

@ -0,0 +1,13 @@
/** @odoo-module */
import { Thread } from "@mail/core/common/thread";
import { patch } from "@web/core/utils/patch";
patch(Thread.prototype, {
isSquashed(msg, prevMsg) {
if (msg.whatsappStatus === "error") {
return false;
}
return super.isSquashed(msg, prevMsg);
},
});

View File

@ -0,0 +1,5 @@
declare module "models" {
export interface DiscussApp {
whatsapp: DiscussAppCategory,
}
}

View File

@ -0,0 +1,33 @@
/* @odoo-module */
import { DiscussApp } from "@mail/core/public_web/discuss_app_model";
import { Record } from "@mail/core/common/record";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
patch(DiscussApp, {
new(data) {
const res = super.new(data);
res.whatsapp = {
extraClass: "o-mail-DiscussSidebarCategory-whatsapp",
icon: "fa fa-whatsapp",
id: "whatsapp",
name: _t("WhatsApp"),
hideWhenEmpty: true,
canView: false,
canAdd: true,
addTitle: _t("Search WhatsApp Channel"),
serverStateKey: "is_discuss_sidebar_category_whatsapp_open",
sequence: 20,
};
return res;
},
});
patch(DiscussApp.prototype, {
setup(env) {
super.setup(env);
this.whatsapp = Record.one("DiscussAppCategory");
},
});

View File

@ -0,0 +1,31 @@
/** @odoo-module */
import { MessagingMenu } from "@mail/core/public_web/messaging_menu";
import { ThreadIcon } from "@mail/core/common/thread_icon";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
patch(MessagingMenu, {
components: { ...MessagingMenu.components, ThreadIcon },
});
patch(MessagingMenu.prototype, {
get tabs() {
const items = super.tabs;
const hasWhatsApp = Object.values(this.store.Thread.records).some(
({ channel_type }) => channel_type === "whatsapp"
);
if (hasWhatsApp) {
items.push({
icon: "fa fa-whatsapp",
id: "whatsapp",
label: _t("WhatsApp"),
});
}
return items;
},
get displayStartConversation() {
return super.displayStartConversation && this.store.discuss.activeTab !== "whatsapp";
},
});

Some files were not shown because too many files have changed in this diff Show More