)
diff --git a/mail_gateway_whatsapp/readme/DESCRIPTION.md b/mail_gateway_whatsapp/readme/DESCRIPTION.md
new file mode 100644
index 000000000..257a3ce65
--- /dev/null
+++ b/mail_gateway_whatsapp/readme/DESCRIPTION.md
@@ -0,0 +1,4 @@
+This module allows to respond whatsapp chats.
+
+This way, a group of users can respond customers or any other set of
+partners in an integrated way.
diff --git a/mail_gateway_whatsapp/readme/ROADMAP.md b/mail_gateway_whatsapp/readme/ROADMAP.md
new file mode 100644
index 000000000..f7a24f2f0
--- /dev/null
+++ b/mail_gateway_whatsapp/readme/ROADMAP.md
@@ -0,0 +1,4 @@
+**WhatsApp templates**
+
+- Add support for Variables
+- Add support for Buttons
diff --git a/mail_gateway_whatsapp/readme/USAGE.md b/mail_gateway_whatsapp/readme/USAGE.md
new file mode 100644
index 000000000..637a66b56
--- /dev/null
+++ b/mail_gateway_whatsapp/readme/USAGE.md
@@ -0,0 +1,7 @@
+1. Access Gateway
+2. Wait until someone starts a conversation.
+3. Now you will be able to respond and receive messages to this person.
+
+The delivery status reported by WhatsApp (sent, delivered or read) is shown
+in the message notification popover. Failed messages are marked as errors with
+the reason returned by WhatsApp.
diff --git a/mail_gateway_whatsapp/security/ir.model.access.csv b/mail_gateway_whatsapp/security/ir.model.access.csv
new file mode 100644
index 000000000..41ae7f726
--- /dev/null
+++ b/mail_gateway_whatsapp/security/ir.model.access.csv
@@ -0,0 +1,8 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_whatsapp_composer,access.whatsapp.composer,model_whatsapp_composer,base.group_user,1,1,1,0
+access_mail_whatsapp_template_group_system,mail_whatsapp_template_group_system,model_mail_whatsapp_template,base.group_system,1,1,1,1
+access_mail_whatsapp_template_group_user,mail_whatsapp_template_group_user,model_mail_whatsapp_template,base.group_user,1,0,0,0
+access_mail_whatsapp_template_button_group_system,access_mail_whatsapp_template_button_group_system,model_mail_whatsapp_template_button,base.group_system,1,1,1,1
+access_mail_whatsapp_template_button_group_user,access_mail_whatsapp_template_button_group_user,model_mail_whatsapp_template_button,base.group_user,1,0,0,0
+access_mail_whatsapp_template_variable_group_system,access_mail_whatsapp_template_variable_group_system,model_mail_whatsapp_template_variable,base.group_system,1,1,1,1
+access_mail_whatsapp_template_variable_group_user,access_mail_whatsapp_template_variable_group_user,model_mail_whatsapp_template_variable,base.group_user,1,0,0,0
diff --git a/mail_gateway_whatsapp/security/security.xml b/mail_gateway_whatsapp/security/security.xml
new file mode 100644
index 000000000..d34fa3a64
--- /dev/null
+++ b/mail_gateway_whatsapp/security/security.xml
@@ -0,0 +1,8 @@
+
+
+
+ WhatsApp template: multicompany
+
+ [('company_id', 'in', company_ids + [False])]
+
+
diff --git a/mail_gateway_whatsapp/static/description/icon.png b/mail_gateway_whatsapp/static/description/icon.png
new file mode 100644
index 000000000..dea8a38ec
Binary files /dev/null and b/mail_gateway_whatsapp/static/description/icon.png differ
diff --git a/mail_gateway_whatsapp/static/description/icon.svg b/mail_gateway_whatsapp/static/description/icon.svg
new file mode 100644
index 000000000..5f34debbe
--- /dev/null
+++ b/mail_gateway_whatsapp/static/description/icon.svg
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
diff --git a/mail_gateway_whatsapp/static/description/index.html b/mail_gateway_whatsapp/static/description/index.html
new file mode 100644
index 000000000..f0607d749
--- /dev/null
+++ b/mail_gateway_whatsapp/static/description/index.html
@@ -0,0 +1,518 @@
+
+
+
+
+
+Mail Whatsapp Gateway
+
+
+
+
+
+
+
+
+
+
+
Mail Whatsapp Gateway
+
+
+
This module allows to respond whatsapp chats.
+
This way, a group of users can respond customers or any other set of
+partners in an integrated way.
+
Table of contents
+
+
+
+
+
+
You need to create a WhatsApp Business Account (WABA), a Meta App and
+define a phone number. You can follow this
+steps .
+
If you create a test Business Account, passwords will change every 24
+hours.
+
In order to make the webhook accessible, the system must be public.
+
+
+
+
+
+
+Access Gateway
+Wait until someone starts a conversation.
+Now you will be able to respond and receive messages to this person.
+
+
The delivery status reported by WhatsApp (sent, delivered or read) is
+shown in the message notification popover. Failed messages are marked as
+errors with the reason returned by WhatsApp.
+
+
+
+
WhatsApp templates
+
+Add support for Variables
+Add support for Buttons
+
+
+
+
+
Bugs are tracked on GitHub Issues .
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us to smash it by providing a detailed and welcomed
+feedback .
+
Do not contact contributors directly about support or help with technical issues.
+
+
+
+
+
+
+
+
+
This module is maintained by the OCA.
+
+
+
+
OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
This module is part of the OCA/mail project on GitHub.
+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute .
+
+
+
+
+
+
diff --git a/mail_gateway_whatsapp/static/src/components/message/message.xml b/mail_gateway_whatsapp/static/src/components/message/message.xml
new file mode 100644
index 000000000..2d81cb10b
--- /dev/null
+++ b/mail_gateway_whatsapp/static/src/components/message/message.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/mail_gateway_whatsapp/static/src/components/message/message_patch.esm.js b/mail_gateway_whatsapp/static/src/components/message/message_patch.esm.js
new file mode 100644
index 000000000..1f59e7c4c
--- /dev/null
+++ b/mail_gateway_whatsapp/static/src/components/message/message_patch.esm.js
@@ -0,0 +1,16 @@
+import {Message} from "@mail/core/common/message";
+import {patch} from "@web/core/utils/patch";
+import {url} from "@web/core/utils/urls";
+
+patch(Message.prototype, {
+ get authorAvatarUrl() {
+ if (
+ this.message.gateway_type &&
+ this.message.gateway_type.includes("whatsapp") &&
+ !["partner", "guest"].includes(this.message.author?.type)
+ ) {
+ return url("/mail_gateway_whatsapp/static/description/icon.png");
+ }
+ return super.authorAvatarUrl;
+ },
+});
diff --git a/mail_gateway_whatsapp/static/src/components/phone_field/phone_field.esm.js b/mail_gateway_whatsapp/static/src/components/phone_field/phone_field.esm.js
new file mode 100644
index 000000000..f2192fb5a
--- /dev/null
+++ b/mail_gateway_whatsapp/static/src/components/phone_field/phone_field.esm.js
@@ -0,0 +1,24 @@
+import {PhoneField} from "@web/views/fields/phone/phone_field";
+import {SendWhatsappButton} from "../send_whatsapp_button/send_whatsapp_button.esm";
+import {patch} from "@web/core/utils/patch";
+
+patch(PhoneField, {
+ components: {
+ ...PhoneField.components,
+ SendWhatsappButton,
+ },
+ defaultProps: {
+ ...PhoneField.defaultProps,
+ enableButton: true,
+ },
+ props: {
+ ...PhoneField.props,
+ enableButton: {type: Boolean, optional: true},
+ },
+ extractProps: ({attrs}) => {
+ return {
+ enableButton: attrs.options.enable_sms,
+ placeholder: attrs.placeholder,
+ };
+ },
+});
diff --git a/mail_gateway_whatsapp/static/src/components/phone_field/phone_field.xml b/mail_gateway_whatsapp/static/src/components/phone_field/phone_field.xml
new file mode 100644
index 000000000..090c5476d
--- /dev/null
+++ b/mail_gateway_whatsapp/static/src/components/phone_field/phone_field.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mail_gateway_whatsapp/static/src/components/send_whatsapp_button/send_whatsapp_button.esm.js b/mail_gateway_whatsapp/static/src/components/send_whatsapp_button/send_whatsapp_button.esm.js
new file mode 100644
index 000000000..b4483f0e0
--- /dev/null
+++ b/mail_gateway_whatsapp/static/src/components/send_whatsapp_button/send_whatsapp_button.esm.js
@@ -0,0 +1,42 @@
+import {Component, status} from "@odoo/owl";
+import {_t} from "@web/core/l10n/translation";
+import {useService} from "@web/core/utils/hooks";
+import {user} from "@web/core/user";
+
+export class SendWhatsappButton extends Component {
+ setup() {
+ this.action = useService("action");
+ this.title = _t("Send Whatsapp Message");
+ }
+ get phoneHref() {
+ return "sms:" + this.props.record.data[this.props.name].replace(/\s+/g, "");
+ }
+ 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,
+ default_res_model: this.props.record.resModel,
+ default_res_id: this.props.record.resId,
+ default_number_field_name: this.props.name,
+ default_composition_mode: "comment",
+ },
+ },
+ {
+ onClose: () => {
+ if (status(this) !== "destroyed") {
+ this.props.record.load();
+ this.props.record.model.notify();
+ }
+ },
+ }
+ );
+ }
+}
+SendWhatsappButton.template = "mail_gateway_whatsapp.SendWhatsappButton";
diff --git a/mail_gateway_whatsapp/static/src/components/send_whatsapp_button/send_whatsapp_button.xml b/mail_gateway_whatsapp/static/src/components/send_whatsapp_button/send_whatsapp_button.xml
new file mode 100644
index 000000000..0be4f5b3d
--- /dev/null
+++ b/mail_gateway_whatsapp/static/src/components/send_whatsapp_button/send_whatsapp_button.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Whatsapp
+
+
+
+
diff --git a/mail_gateway_whatsapp/static/src/models/notification_model_patch.esm.js b/mail_gateway_whatsapp/static/src/models/notification_model_patch.esm.js
new file mode 100644
index 000000000..c81e602aa
--- /dev/null
+++ b/mail_gateway_whatsapp/static/src/models/notification_model_patch.esm.js
@@ -0,0 +1,33 @@
+import {Notification} from "@mail/core/common/notification_model";
+import {_t} from "@web/core/l10n/translation";
+import {patch} from "@web/core/utils/patch";
+
+const WHATSAPP_STATUS_ICONS = {
+ sent: "fa fa-whatsapp text-muted",
+ delivered: "fa fa-whatsapp text-success",
+ read: "fa fa-whatsapp text-info",
+};
+
+patch(Notification.prototype, {
+ get isWhatsappSent() {
+ return this.gateway_type === "whatsapp" && this.notification_status === "sent";
+ },
+ get statusIcon() {
+ if (this.isWhatsappSent && this.whatsapp_status) {
+ return WHATSAPP_STATUS_ICONS[this.whatsapp_status];
+ }
+ return super.statusIcon;
+ },
+ get statusTitle() {
+ if (!this.isWhatsappSent) {
+ return super.statusTitle;
+ }
+ switch (this.whatsapp_status) {
+ case "delivered":
+ return _t("Delivered");
+ case "read":
+ return _t("Read");
+ }
+ return _t("Sent");
+ },
+});
diff --git a/mail_gateway_whatsapp/tests/__init__.py b/mail_gateway_whatsapp/tests/__init__.py
new file mode 100644
index 000000000..ffbc8278d
--- /dev/null
+++ b/mail_gateway_whatsapp/tests/__init__.py
@@ -0,0 +1,3 @@
+from . import test_mail_gateway_whatsapp
+from . import test_mail_whatsapp_template
+from . import test_mail_whatsapp_coverage
diff --git a/mail_gateway_whatsapp/tests/test_mail_gateway_whatsapp.py b/mail_gateway_whatsapp/tests/test_mail_gateway_whatsapp.py
new file mode 100644
index 000000000..3850c96d0
--- /dev/null
+++ b/mail_gateway_whatsapp/tests/test_mail_gateway_whatsapp.py
@@ -0,0 +1,768 @@
+# Copyright 2022 CreuBlanca
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+import copy
+import hashlib
+import hmac
+import json
+from unittest.mock import MagicMock, patch
+
+from markupsafe import Markup
+
+from odoo import Command
+from odoo.exceptions import UserError
+from odoo.tests import Form, HttpCase, RecordCapturer
+from odoo.tests.common import new_test_user, tagged
+from odoo.tools import mute_logger
+
+from odoo.addons.mail_gateway.tests.common import MailGatewayTestCase
+
+
+@tagged("-at_install", "post_install")
+class TestMailGatewayWhatsApp(MailGatewayTestCase, HttpCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.webhook = "demo_hook"
+ cls.user = new_test_user(
+ cls.env,
+ login="gateway_test_user",
+ groups="base.group_user,mail_gateway.gateway_user",
+ )
+ cls.webhook_user = new_test_user(
+ cls.env, login="webhook_test_user", groups="base.group_user"
+ )
+ cls.gateway = cls.env["mail.gateway"].create(
+ {
+ "name": "gateway",
+ "gateway_type": "whatsapp",
+ "token": "token",
+ "whatsapp_security_key": "key",
+ "webhook_secret": "MY-SECRET",
+ "webhook_user_id": cls.user.id,
+ "member_ids": [(4, cls.user.id)],
+ }
+ )
+ cls.ws_template = cls.env["mail.whatsapp.template"].create(
+ {
+ "name": "New template",
+ "category": "marketing",
+ "language": "es",
+ "body": "Demo template",
+ "state": "approved",
+ "is_supported": True,
+ "gateway_id": cls.gateway.id,
+ }
+ )
+ cls.partner = cls.env["res.partner"].create(
+ {"name": "Partner", "phone": "+34 600 000 000"}
+ )
+ cls.password = "my_new_password"
+ cls.message_01 = {
+ "object": "whatsapp_business_account",
+ "entry": [
+ {
+ "id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
+ "changes": [
+ {
+ "value": {
+ "messaging_product": "whatsapp",
+ "metadata": {
+ "display_phone_number": "1234",
+ "phone_number_id": "34699999999",
+ },
+ "contacts": [
+ {
+ "profile": {"name": "NAME"},
+ "wa_id": "34699999999",
+ }
+ ],
+ "messages": [
+ {
+ "from": "34699999999",
+ "id": "wamid.ID",
+ "timestamp": "1234",
+ "text": {"body": "MESSAGE_BODY"},
+ "type": "text",
+ }
+ ],
+ },
+ "field": "messages",
+ }
+ ],
+ }
+ ],
+ }
+ cls.message_02 = {
+ "object": "whatsapp_business_account",
+ "entry": [
+ {
+ "id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
+ "changes": [
+ {
+ "value": {
+ "messaging_product": "whatsapp",
+ "metadata": {
+ "display_phone_number": "1234",
+ "phone_number_id": "1234",
+ },
+ "contacts": [
+ {"profile": {"name": "NAME"}, "wa_id": "1234"}
+ ],
+ "messages": [
+ {
+ "from": "1234",
+ "id": "wamid.ID",
+ "timestamp": "1234",
+ "type": "image",
+ "image": {
+ "caption": "CAPTION",
+ "mime_type": "image/jpeg",
+ "sha256": "IMAGE_HASH",
+ "id": "12356",
+ },
+ }
+ ],
+ },
+ "field": "messages",
+ }
+ ],
+ }
+ ],
+ }
+ cls.audio_message = {
+ "object": "whatsapp_business_account",
+ "entry": [
+ {
+ "id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
+ "changes": [
+ {
+ "value": {
+ "messaging_product": "whatsapp",
+ "metadata": {
+ "display_phone_number": "1234",
+ "phone_number_id": "1234",
+ },
+ "contacts": [
+ {"profile": {"name": "NAME"}, "wa_id": "1234"}
+ ],
+ "messages": [
+ {
+ "from": "1234",
+ "id": "wamid.ID",
+ "timestamp": "1234",
+ "type": "audio",
+ "audio": {
+ "mime_type": "audio/oga",
+ "sha256": "IMAGE_HASH",
+ "id": "12356",
+ },
+ }
+ ],
+ },
+ "field": "messages",
+ }
+ ],
+ }
+ ],
+ }
+
+ def test_webhook_management(self):
+ self.gateway.webhook_key = self.webhook
+ self.assertTrue(self.gateway.can_set_webhook)
+ self.gateway.set_webhook()
+ self.assertEqual(self.gateway.integrated_webhook_state, "pending")
+ self.gateway.remove_webhook()
+ self.assertFalse(self.gateway.integrated_webhook_state)
+ self.gateway.set_webhook()
+ self.assertEqual(self.gateway.integrated_webhook_state, "pending")
+ self.url_open(
+ "/gateway/{}/{}/update?hub.verify_token={}&hub.challenge={}".format(
+ self.gateway.gateway_type,
+ self.webhook,
+ self.gateway.whatsapp_security_key + "12",
+ "22",
+ ),
+ ).raise_for_status()
+ self.assertEqual(self.gateway.integrated_webhook_state, "pending")
+ self.integrate_webhook()
+ self.assertEqual(self.gateway.integrated_webhook_state, "integrated")
+ self.gateway.remove_webhook()
+ self.assertFalse(self.gateway.integrated_webhook_state)
+
+ def integrate_webhook(self):
+ self.url_open(
+ "/gateway/{}/{}/update?hub.verify_token={}&hub.challenge={}".format(
+ self.gateway.gateway_type,
+ self.webhook,
+ self.gateway.whatsapp_security_key,
+ "22",
+ ),
+ ).raise_for_status()
+
+ def set_message(self, message, webhook, headers=True):
+ data = json.dumps(message)
+ headers_dict = {"Content-Type": "application/json"}
+ if headers:
+ hex_dig = hmac.new(
+ self.gateway.webhook_secret.encode(),
+ data.encode(),
+ hashlib.sha256,
+ ).hexdigest()
+ headers_dict["x-hub-signature-256"] = f"sha256={hex_dig}"
+ self.url_open(
+ f"/gateway/{self.gateway.gateway_type}/{webhook}/update",
+ data=data,
+ headers=headers_dict,
+ ).raise_for_status()
+
+ def receive_message(self, message):
+ self.gateway.webhook_key = self.webhook
+ self.gateway.set_webhook()
+ self.integrate_webhook()
+ self.set_message(message, self.webhook)
+ chat = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+ self.assertTrue(chat)
+ self.assertTrue(chat.message_ids)
+ return chat.message_ids
+
+ def test_receive_message_01(self):
+ message = self.receive_message(self.message_01)
+ self.assertFalse(message.author_id)
+ self.assertFalse(message.parent_id)
+
+ def test_receive_message_02(self):
+ # Check that the partner is assigned automatically
+ partner = self.env["res.partner"].create(
+ {"name": "DEMO", "phone": "+34699999999"}
+ )
+ message = self.receive_message(self.message_01)
+ self.assertEqual(message.author_id, partner)
+ self.assertFalse(message.parent_id)
+
+ def test_receive_message_internal_webhook_user(self):
+ # A regular internal user must be able to create the channel of a new
+ # contact, even if the HTTP request starts as the public user
+ self.gateway.webhook_user_id = self.env.ref("base.user_admin")
+ message = self.receive_message(self.message_01)
+ self.assertFalse(message.author_id)
+
+ def test_receive_message_gateway_webhook_user(self):
+ # A gateway user that is not an administrator can process the webhook:
+ # create the guest of a new contact and copy a reply on a linked record
+ self.gateway.webhook_user_id = self.env["res.users"].create(
+ {
+ "name": "Webhook user",
+ "login": "webhook_user",
+ "group_ids": [
+ Command.set(
+ [
+ self.env.ref("base.group_user").id,
+ self.env.ref("mail_gateway.gateway_user").id,
+ ]
+ )
+ ],
+ }
+ )
+ message = self.receive_message(self.message_01)
+ self.assertTrue(message.author_guest_id)
+ chat = self.env["discuss.channel"].browse(message.res_id)
+ response = MagicMock()
+ response.json.return_value = {"messages": [{"id": "wamid.SENT"}]}
+ with patch("requests.post", return_value=response):
+ sent = chat.message_post(
+ body="Question",
+ message_type="comment",
+ subtype_xmlid="mail.mt_comment",
+ )
+ sent.gateway_message_id = self.partner.message_post(
+ body="Question", message_type="comment"
+ )
+ reply = copy.deepcopy(self.message_01)
+ reply_value = reply["entry"][0]["changes"][0]["value"]["messages"][0]
+ reply_value["id"] = "wamid.REPLY"
+ reply_value["context"] = {"id": "wamid.SENT"}
+ self.set_message(reply, self.webhook)
+ reply_message = chat.message_ids.filtered(lambda m: m.parent_id == sent)
+ self.assertTrue(reply_message)
+ self.assertEqual(reply_message.gateway_message_id.res_id, self.partner.id)
+
+ def test_receive_related_message(self):
+ """
+ Send outgoing message, and then receive a reply message
+ with reference to the original.
+ """
+ # Send outgoing
+ partner = self.env["res.partner"].create(
+ {"name": "DEMO", "phone": "+34699999999"}
+ )
+ ctx = {
+ "default_res_model": partner._name,
+ "default_res_id": partner.id,
+ "default_number_field_name": "phone",
+ "default_composition_mode": "comment",
+ "default_gateway_id": self.gateway.id,
+ }
+ self.gateway.whatsapp_account_id = "123456"
+ form_composer = Form(self.env["whatsapp.composer"].with_context(**ctx))
+ form_composer.body = "Body test"
+ form_composer.template_id = self.ws_template
+ composer = form_composer.save()
+ channel = partner._whatsapp_get_channel(
+ composer.number_field_name, composer.gateway_id
+ )
+ message_domain = [
+ ("gateway_type", "=", "whatsapp"),
+ ("model", "=", channel._name),
+ ("res_id", "=", channel.id),
+ ]
+ ID_WHATSAPP = "wamid.TEST_UNIQUE_ID_123"
+ with (
+ RecordCapturer(self.env["mail.message"], message_domain) as capture,
+ patch("requests.post") as post_mock,
+ ):
+ response_mock = MagicMock()
+ response_mock.status_code = 200
+ # Mock the response of whatsapp to include an assigned ID
+ response_mock.json.return_value = {"messages": [{"id": ID_WHATSAPP}]}
+ post_mock.return_value = response_mock
+ composer.action_send_whatsapp()
+ message = capture.records
+ # Check that the sent message notification contains the received Whatsapp ID
+ self.assertTrue(
+ message.notification_ids.filtered(
+ lambda msg: msg.gateway_message_id == ID_WHATSAPP
+ )
+ )
+ # Receive message 01 with related id set.
+ reply_message = self.message_01
+ reply_message["entry"][0]["changes"][0]["value"]["messages"][0]["context"] = {
+ "id": ID_WHATSAPP
+ }
+ received_reply = self.receive_message(reply_message)
+ self.assertEqual(message.id, received_reply.parent_id.id)
+
+ def test_receive_message_03(self):
+ class GetImageResponse:
+ def raise_for_status(self):
+ pass
+
+ def json(self):
+ return {"url": "http://demo.url", "mime_type": "image/png"}
+
+ content = b"binary_data"
+
+ with patch("requests.get") as get_mock:
+ get_mock.return_value = GetImageResponse()
+ message = self.receive_message(self.message_02)
+ self.assertEqual(message.body, "CAPTION
")
+
+ def test_receive_audio_message(self):
+ class GetAudioResponse:
+ def raise_for_status(self):
+ pass
+
+ def json(self):
+ return {"url": "http://demo.url", "mime_type": "audio/oga"}
+
+ content = b"binary_data"
+
+ with patch("requests.get") as get_mock:
+ get_mock.return_value = GetAudioResponse()
+ message = self.receive_message(self.audio_message)
+ # Check that the attachment is marked as voice
+ self.assertTrue(message.attachment_ids.voice_ids)
+
+ def test_receive_message_keeps_layout(self):
+ """Text keeps its layout, markup stays literal, location stays a link."""
+ message = copy.deepcopy(self.message_01)
+ content = message["entry"][0]["changes"][0]["value"]["messages"][0]
+ # Plain text, brackets included: what a sender typing "" really sends.
+ content["text"] = {
+ "body": "Line 1\nbold \n\nSection 2 https://example.com/track"
+ }
+ content["location"] = {"latitude": "41.3851", "longitude": "2.1734"}
+ body = self.receive_message(message).body
+ self.assertIn("Line 1 ", body)
+ self.assertIn('href="https://example.com/track"', body)
+ self.assertIn("<b>bold</b>", body)
+ self.assertIn("41.3851,2.1734", body)
+ self.assertNotIn("<a", body)
+
+ def test_receive_message_formatting(self):
+ """Whatsapp text markers are rendered as html."""
+ message = copy.deepcopy(self.message_01)
+ content = message["entry"][0]["changes"][0]["value"]["messages"][0]
+ content["text"] = {"body": "*bold* _italic_ https://example.com/a_b_c"}
+ body = self.receive_message(message).body
+ self.assertIn("bold ", body)
+ self.assertIn("italic ", body)
+ # The markers of a link belong to the link.
+ self.assertIn('href="https://example.com/a_b_c"', body)
+
+ @mute_logger("odoo.addons.mail_gateway.controllers.gateway")
+ def test_post_no_signature_no_message(self):
+ self.gateway.webhook_key = self.webhook
+ self.gateway.set_webhook()
+ self.integrate_webhook()
+ self.set_message(self.message_01, self.webhook, False)
+ self.assertFalse(
+ self.env["discuss.channel"].search([("gateway_id", "=", self.gateway.id)])
+ )
+
+ @mute_logger("odoo.addons.mail_gateway.controllers.gateway")
+ def test_post_wrong_signature_no_message(self):
+ self.gateway.webhook_key = self.webhook
+ self.gateway.set_webhook()
+ self.integrate_webhook()
+ data = json.dumps(self.message_01)
+ hex_dig = hmac.new(
+ self.gateway.webhook_secret.encode(),
+ data.encode(),
+ hashlib.sha256,
+ ).hexdigest()
+ headers = {
+ "Content-Type": "application/json",
+ "x-hub-signature-256": f"sha256=1234{hex_dig}",
+ }
+ self.url_open(
+ f"/gateway/{self.gateway.gateway_type}/{self.webhook}/update",
+ data=data,
+ headers=headers,
+ ).raise_for_status()
+ self.assertFalse(
+ self.env["discuss.channel"].search([("gateway_id", "=", self.gateway.id)])
+ )
+
+ def test_send_image(self):
+ self.gateway.webhook_key = self.webhook
+ self.gateway.set_webhook()
+ self.integrate_webhook()
+ composer = self.env["whatsapp.composer"].create(
+ {
+ "res_model": self.partner._name,
+ "res_id": self.partner.id,
+ "number_field_name": "phone",
+ "gateway_id": self.gateway.id,
+ }
+ )
+ composer.action_view_whatsapp()
+ channel = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+
+ with patch("requests.post") as post_mock:
+ post_mock.return_value = MagicMock()
+ channel.message_post(
+ attachments=[("demo.png", b"IMAGE")],
+ subtype_xmlid="mail.mt_comment",
+ message_type="comment",
+ )
+ post_mock.assert_called()
+ self.assertEqual(post_mock.call_count, 2)
+
+ def test_send_message_formatting(self):
+ """Html formatting is sent as whatsapp text markers."""
+ self.gateway.webhook_key = self.webhook
+ self.gateway.set_webhook()
+ self.integrate_webhook()
+ composer = self.env["whatsapp.composer"].create(
+ {
+ "res_model": self.partner._name,
+ "res_id": self.partner.id,
+ "number_field_name": "phone",
+ "gateway_id": self.gateway.id,
+ }
+ )
+ composer.action_view_whatsapp()
+ channel = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+ with patch("requests.post") as post_mock:
+ post_mock.return_value = MagicMock()
+ channel.message_post(
+ body=Markup("
bold and italic
"),
+ subtype_xmlid="mail.mt_comment",
+ message_type="comment",
+ )
+ payload = post_mock.call_args.kwargs["json"]
+ self.assertEqual(payload["text"]["body"], "*bold* and _italic_")
+
+ def test_send_document_error(self):
+ self.gateway.webhook_key = self.webhook
+ self.gateway.set_webhook()
+ self.integrate_webhook()
+ composer = self.env["whatsapp.composer"].create(
+ {
+ "res_model": self.partner._name,
+ "res_id": self.partner.id,
+ "number_field_name": "phone",
+ "gateway_id": self.gateway.id,
+ }
+ )
+ composer.action_view_whatsapp()
+ channel = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+ with mute_logger(
+ "odoo.addons.mail_gateway_whatsapp.models.mail_gateway_whatsapp"
+ ):
+ message = channel.message_post(
+ attachments=[("demo.xml", b"IMAGE")],
+ subtype_xmlid="mail.mt_comment",
+ message_type="comment",
+ )
+ self.assertEqual(message.notification_ids.notification_status, "exception")
+
+ def test_send_message_text(self):
+ """
+ Test that the message is sent correctly
+ - First message need a template
+ - Second message does not need a template
+ """
+ ctx = {
+ "default_res_model": self.partner._name,
+ "default_res_id": self.partner.id,
+ "default_number_field_name": "phone",
+ "default_composition_mode": "comment",
+ "default_gateway_id": self.gateway.id,
+ }
+ self.gateway.whatsapp_account_id = "123456"
+ form_composer = Form(self.env["whatsapp.composer"].with_context(**ctx))
+ form_composer.body = "Body test"
+ self.assertTrue(form_composer.is_required_template)
+ self.assertTrue(form_composer._get_modifier("template_id", "required"))
+ form_composer.template_id = self.ws_template
+ composer = form_composer.save()
+ self.assertEqual(composer.body, "Demo template")
+ channel = self.partner._whatsapp_get_channel(
+ composer.number_field_name, composer.gateway_id
+ )
+ message_domain = [
+ ("gateway_type", "=", "whatsapp"),
+ ("model", "=", channel._name),
+ ("res_id", "=", channel.id),
+ ]
+ with (
+ RecordCapturer(self.env["mail.message"], message_domain) as capture,
+ patch("requests.post") as post_mock,
+ ):
+ post_mock.return_value = MagicMock()
+ composer.action_send_whatsapp()
+ self.assertEqual(len(capture.records), 1)
+ self.assertEqual(capture.records.body, Markup("Demo template
"))
+ # second message
+ form_composer = Form(self.env["whatsapp.composer"].with_context(**ctx))
+ form_composer.body = "Body test"
+ self.assertFalse(form_composer.is_required_template)
+ self.assertFalse(form_composer._get_modifier("template_id", "required"))
+ composer = form_composer.save()
+ self.assertEqual(composer.body, "Body test")
+ channel = self.partner._whatsapp_get_channel(
+ composer.number_field_name, composer.gateway_id
+ )
+ message_domain = [
+ ("gateway_type", "=", "whatsapp"),
+ ("model", "=", channel._name),
+ ("res_id", "=", channel.id),
+ ]
+ with (
+ RecordCapturer(self.env["mail.message"], message_domain) as capture,
+ patch("requests.post") as post_mock,
+ ):
+ post_mock.return_value = MagicMock()
+ composer.action_send_whatsapp()
+ self.assertEqual(len(capture.records), 1)
+ self.assertEqual(capture.records.body, Markup("Body test
"))
+
+ def test_compose(self):
+ self.gateway.webhook_key = self.webhook
+ self.gateway.set_webhook()
+ self.integrate_webhook()
+ composer = self.env["whatsapp.composer"].create(
+ {
+ "res_model": self.partner._name,
+ "res_id": self.partner.id,
+ "number_field_name": "phone",
+ "gateway_id": self.gateway.id,
+ }
+ )
+ composer.action_view_whatsapp()
+ channel = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+ self.assertTrue(channel)
+ self.assertFalse(channel.message_ids)
+ with self.assertRaises(UserError):
+ composer.action_send_whatsapp()
+ composer.body = "DEMO"
+ with patch("requests.post") as post_mock:
+ post_mock.return_value = MagicMock()
+ composer.action_send_whatsapp()
+ post_mock.assert_called()
+ channel.invalidate_recordset()
+ self.assertTrue(channel.message_ids)
+
+ def test_send_message_with_variable(self):
+ ctx = {
+ "default_res_model": self.partner._name,
+ "default_res_id": self.partner.id,
+ "default_number_field_name": "phone",
+ "default_composition_mode": "comment",
+ "default_gateway_id": self.gateway.id,
+ }
+ tmpl_with_vars = self.env["mail.whatsapp.template"].create(
+ {
+ "name": "Partner Vars",
+ "category": "utility",
+ "language": "es",
+ "header": "Hi {{1}}",
+ "body": "Name: {{1}} · Tel: {{2}}",
+ "gateway_id": self.gateway.id,
+ "variable_ids": [Command.clear()],
+ "state": "approved",
+ "is_supported": True,
+ "model_id": self.env["ir.model"]._get("res.partner").id,
+ }
+ )
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{1}}",
+ "line_type": "header",
+ "template_id": tmpl_with_vars.id,
+ "field_name": "name",
+ }
+ )
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{1}}",
+ "line_type": "body",
+ "template_id": tmpl_with_vars.id,
+ "field_name": "name",
+ }
+ )
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{2}}",
+ "line_type": "body",
+ "template_id": tmpl_with_vars.id,
+ "field_name": "phone",
+ }
+ )
+ self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "phone",
+ "button_type": "phone_number",
+ "template_id": tmpl_with_vars.id,
+ "call_number": "+34666555444",
+ }
+ )
+ self.assertEqual(len(tmpl_with_vars.variable_ids), 3)
+ self.assertEqual(len(tmpl_with_vars.button_ids), 1)
+ self.gateway.whatsapp_account_id = "123456"
+ form_composer = Form(self.env["whatsapp.composer"].with_context(**ctx))
+ form_composer.template_id = tmpl_with_vars
+ self.assertTrue(form_composer.is_required_template)
+ self.assertTrue(form_composer._get_modifier("template_id", "required"))
+ composer = form_composer.save()
+ channel = self.partner._whatsapp_get_channel(
+ composer.number_field_name, composer.gateway_id
+ )
+ message_domain = [
+ ("gateway_type", "=", "whatsapp"),
+ ("model", "=", channel._name),
+ ("res_id", "=", channel.id),
+ ]
+ with (
+ RecordCapturer(self.env["mail.message"], message_domain) as capture,
+ patch("requests.post") as post_mock,
+ ):
+ post_mock.return_value = MagicMock()
+ composer.action_send_whatsapp()
+ # Assertions
+ self.assertEqual(len(capture.records), 1)
+ channel.invalidate_recordset()
+ self.assertTrue(channel.message_ids)
+
+ def test_send_message_with_dynamic_button(self):
+ ctx = {
+ "default_res_model": self.partner._name,
+ "default_res_id": self.partner.id,
+ "default_number_field_name": "phone",
+ "default_composition_mode": "comment",
+ "default_gateway_id": self.gateway.id,
+ }
+ tmpl_with_vars_dynamic = self.env["mail.whatsapp.template"].create(
+ {
+ "name": "Dynamic Button",
+ "category": "utility",
+ "language": "es",
+ "body": "Name: {{1}} · Tel: {{2}}",
+ "gateway_id": self.gateway.id,
+ "variable_ids": [Command.clear()],
+ "state": "approved",
+ "is_supported": True,
+ "model_id": self.env["ir.model"]._get("res.partner").id,
+ }
+ )
+ self.assertEqual(len(tmpl_with_vars_dynamic.variable_ids), 0)
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{1}}",
+ "line_type": "body",
+ "template_id": tmpl_with_vars_dynamic.id,
+ "field_name": "name",
+ }
+ )
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{2}}",
+ "line_type": "body",
+ "template_id": tmpl_with_vars_dynamic.id,
+ "field_name": "phone",
+ }
+ )
+ self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "1",
+ "button_type": "url",
+ "url_type": "dynamic",
+ "template_id": tmpl_with_vars_dynamic.id,
+ "website_url": "https://www.odoo.com",
+ }
+ )
+ self.assertEqual(len(tmpl_with_vars_dynamic.variable_ids), 3)
+ button = tmpl_with_vars_dynamic.variable_ids.search(
+ [
+ ("line_type", "=", "button"),
+ ("template_id", "=", tmpl_with_vars_dynamic.id),
+ ]
+ )
+ button.field_name = "name"
+ self.gateway.whatsapp_account_id = "123456"
+ form_composer = Form(self.env["whatsapp.composer"].with_context(**ctx))
+ form_composer.template_id = tmpl_with_vars_dynamic
+ self.assertTrue(form_composer.is_required_template)
+ self.assertTrue(form_composer._get_modifier("template_id", "required"))
+ composer = form_composer.save()
+ channel = self.partner._whatsapp_get_channel(
+ composer.number_field_name, composer.gateway_id
+ )
+ message_domain = [
+ ("gateway_type", "=", "whatsapp"),
+ ("model", "=", channel._name),
+ ("res_id", "=", channel.id),
+ ]
+ with (
+ RecordCapturer(self.env["mail.message"], message_domain) as capture,
+ patch("requests.post") as post_mock,
+ ):
+ post_mock.return_value = MagicMock()
+ composer.action_send_whatsapp()
+ # Assertions
+ self.assertEqual(len(capture.records), 1)
+ channel.invalidate_recordset()
+ self.assertTrue(channel.message_ids)
diff --git a/mail_gateway_whatsapp/tests/test_mail_whatsapp_coverage.py b/mail_gateway_whatsapp/tests/test_mail_whatsapp_coverage.py
new file mode 100644
index 000000000..e943d4ca4
--- /dev/null
+++ b/mail_gateway_whatsapp/tests/test_mail_whatsapp_coverage.py
@@ -0,0 +1,1154 @@
+# Copyright 2025 Komit
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+import json
+from unittest.mock import MagicMock, patch
+
+import requests
+
+from odoo import Command
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests.common import tagged
+from odoo.tools import mute_logger
+
+from odoo.addons.base.models.ir_mail_server import MailDeliveryException
+from odoo.addons.mail_gateway.tests.common import MailGatewayTestCase
+
+
+@tagged("-at_install", "post_install")
+class TestMailWhatsAppCoverage(MailGatewayTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.gateway = cls.env["mail.gateway"].create(
+ {
+ "name": "gateway",
+ "gateway_type": "whatsapp",
+ "token": "token",
+ "whatsapp_security_key": "key",
+ "whatsapp_account_id": "123456",
+ "whatsapp_from_phone": "111",
+ "webhook_secret": "MY-SECRET",
+ "member_ids": [(4, cls.env.user.id)],
+ }
+ )
+ cls.partner = cls.env["res.partner"].create(
+ {"name": "Ada Lovelace", "phone": "+34900111222"}
+ )
+
+ def _create_template(self, **kwargs):
+ vals = {
+ "name": "Template",
+ "category": "utility",
+ "language": "es",
+ "body": "Body",
+ "gateway_id": self.gateway.id,
+ "state": "approved",
+ "is_supported": True,
+ }
+ vals.update(kwargs)
+ return self.env["mail.whatsapp.template"].create(vals)
+
+ # ------------------------------------------------------------------
+ # Template constraints
+ # ------------------------------------------------------------------
+ def test_check_buttons_max_total(self):
+ tmpl = self._create_template()
+ with self.assertRaisesRegex(ValidationError, "maximum of 10 buttons"):
+ tmpl.write(
+ {
+ "button_ids": [
+ Command.create({"name": f"B{i}", "button_type": "quick_reply"})
+ for i in range(11)
+ ]
+ }
+ )
+
+ def test_check_buttons_max_url(self):
+ tmpl = self._create_template()
+ with self.assertRaisesRegex(ValidationError, "maximum of 2 URL buttons"):
+ tmpl.write(
+ {
+ "button_ids": [
+ Command.create(
+ {
+ "name": f"U{i}",
+ "button_type": "url",
+ "website_url": "https://example.com",
+ }
+ )
+ for i in range(3)
+ ]
+ }
+ )
+
+ def test_check_buttons_max_phone(self):
+ tmpl = self._create_template()
+ with self.assertRaisesRegex(ValidationError, "maximum of 1 Call Number"):
+ tmpl.write(
+ {
+ "button_ids": [
+ Command.create(
+ {
+ "name": f"P{i}",
+ "button_type": "phone_number",
+ "call_number": "+34600000000",
+ }
+ )
+ for i in range(2)
+ ]
+ }
+ )
+
+ def test_check_variables_two_headers(self):
+ with self.assertRaisesRegex(
+ ValidationError, "exactly 1 variable in the header"
+ ):
+ self._create_template(
+ header="{{1}} {{2}}",
+ variable_ids=[
+ Command.create(
+ {"name": "{{1}}", "line_type": "header", "field_name": "name"}
+ ),
+ Command.create(
+ {"name": "{{2}}", "line_type": "header", "field_name": "phone"}
+ ),
+ ],
+ )
+
+ def test_check_variables_header_index(self):
+ with self.assertRaisesRegex(ValidationError, r"header should be used as"):
+ self._create_template(
+ header="{{2}}",
+ variable_ids=[
+ Command.create(
+ {"name": "{{2}}", "line_type": "header", "field_name": "name"}
+ ),
+ ],
+ )
+
+ def test_check_variables_body_gap(self):
+ with self.assertRaisesRegex(ValidationError, "should start at 1"):
+ self._create_template(
+ body="{{1}} {{3}}",
+ variable_ids=[
+ Command.create(
+ {"name": "{{1}}", "line_type": "body", "field_name": "name"}
+ ),
+ Command.create(
+ {"name": "{{3}}", "line_type": "body", "field_name": "phone"}
+ ),
+ ],
+ )
+
+ def test_variable_check_name_invalid(self):
+ tmpl = self._create_template()
+ with self.assertRaisesRegex(ValidationError, "format"):
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "not-a-var",
+ "line_type": "body",
+ "template_id": tmpl.id,
+ "field_name": "name",
+ }
+ )
+
+ def test_variable_check_field_name_missing(self):
+ tmpl = self._create_template()
+ with self.assertRaisesRegex(ValidationError, "associated with a field"):
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{1}}",
+ "line_type": "body",
+ "template_id": tmpl.id,
+ "field_name": False,
+ }
+ )
+
+ def test_variable_check_field_name_invalid_path(self):
+ tmpl = self._create_template()
+ with self.assertRaisesRegex(ValidationError, "valid field path"):
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{1}}",
+ "line_type": "body",
+ "template_id": tmpl.id,
+ "field_name": "not_a_real_field",
+ }
+ )
+
+ # ------------------------------------------------------------------
+ # Button constraints
+ # ------------------------------------------------------------------
+ def test_button_invalid_url(self):
+ tmpl = self._create_template()
+ with self.assertRaisesRegex(ValidationError, "valid URL"):
+ self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "Web",
+ "button_type": "url",
+ "website_url": "not-a-url",
+ "template_id": tmpl.id,
+ }
+ )
+
+ def test_button_check_variable_ids(self):
+ tmpl = self._create_template()
+ static_button = self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "Static",
+ "button_type": "url",
+ "url_type": "static",
+ "website_url": "https://example.com",
+ "template_id": tmpl.id,
+ }
+ )
+ # A dynamic url auto-creates its placeholder variable
+ dynamic_button = self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "{{1}}",
+ "button_type": "url",
+ "url_type": "dynamic",
+ "website_url": "https://example.com/",
+ "template_id": tmpl.id,
+ }
+ )
+ self.assertTrue(dynamic_button.variable_ids)
+ dynamic_button.check_variable_ids()
+ # A static url with a placeholder variable is invalid
+ static_button.variable_ids = [
+ Command.create(
+ {
+ "name": "{{1}}",
+ "line_type": "button",
+ "template_id": tmpl.id,
+ "field_name": "name",
+ }
+ )
+ ]
+ with self.assertRaises(ValidationError):
+ static_button.check_variable_ids()
+
+ # ------------------------------------------------------------------
+ # Export / import component building
+ # ------------------------------------------------------------------
+ def test_prepare_components_to_export(self):
+ tmpl = self._create_template(
+ header="Hello {{1}}",
+ footer="Bye",
+ body="Body {{1}}",
+ variable_ids=[
+ Command.create(
+ {"name": "{{1}}", "line_type": "header", "field_name": "name"}
+ ),
+ Command.create(
+ {"name": "{{1}}", "line_type": "body", "field_name": "name"}
+ ),
+ ],
+ )
+ self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "{{1}}",
+ "button_type": "url",
+ "url_type": "dynamic",
+ "website_url": "https://example.com/",
+ "template_id": tmpl.id,
+ }
+ )
+ self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "Call",
+ "button_type": "phone_number",
+ "call_number": "+34600000000",
+ "template_id": tmpl.id,
+ }
+ )
+ components = tmpl._prepare_components_to_export()
+ types = {c["type"] for c in components}
+ self.assertEqual(types, {"BODY", "HEADER", "FOOTER", "BUTTONS"})
+ buttons = next(c for c in components if c["type"] == "BUTTONS")["buttons"]
+ button_types = {b["type"] for b in buttons}
+ self.assertEqual(button_types, {"URL", "PHONE_NUMBER"})
+
+ def test_prepare_values_to_import_button_update_and_unsupported(self):
+ tmpl = self._create_template(template_uid="999")
+ existing = self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "Existing",
+ "button_type": "quick_reply",
+ "template_id": tmpl.id,
+ }
+ )
+ json_data = {
+ "name": "imported_tmpl",
+ "category": "MARKETING",
+ "language": "es",
+ "status": "APPROVED",
+ "id": "999",
+ "components": [
+ {"type": "BODY", "text": "Body"},
+ {
+ "type": "BUTTONS",
+ "buttons": [{"type": "QUICK_REPLY", "text": "Existing"}],
+ },
+ {"type": "CAROUSEL"}, # unsupported -> is_supported False
+ ],
+ }
+ vals = tmpl._prepare_values_to_import(self.gateway, json_data)
+ self.assertFalse(vals["is_supported"])
+ # button update command referencing the existing button
+ button_cmds = vals["button_ids"]
+ self.assertTrue(
+ any(
+ cmd[0] == Command.UPDATE and cmd[1] == existing.id
+ for cmd in button_cmds
+ )
+ )
+
+ def test_button_export_template_http_error(self):
+ tmpl = self._create_template()
+
+ def _bad_response(*args, **kwargs):
+ response = requests.Response()
+ response.status_code = 400
+ response._content = b'{"error": "bad"}'
+ response.url = args[0] if args else ""
+ return response
+
+ with patch.object(requests, "post", _bad_response):
+ with self.assertRaises(UserError):
+ tmpl.button_export_template()
+
+ # a non-HTTP error is also wrapped into a UserError
+ def _raise(*args, **kwargs):
+ raise ValueError("boom")
+
+ with patch.object(requests, "post", _raise):
+ with self.assertRaises(UserError):
+ tmpl.button_export_template()
+
+ def test_sync_template_error(self):
+ tmpl = self._create_template(template_uid="123")
+
+ def _raise(*args, **kwargs):
+ raise ValueError("boom")
+
+ with patch.object(requests, "get", _raise):
+ with self.assertRaises(UserError):
+ tmpl.button_sync_template()
+
+ def test_import_template_request_error(self):
+ self.gateway.whatsapp_account_id = "123456"
+
+ def _raise(*args, **kwargs):
+ raise ValueError("network down")
+
+ with patch.object(requests, "get", _raise):
+ with self.assertRaises(UserError):
+ self.gateway.button_import_whatsapp_template()
+
+ def test_button_dynamic_placeholder_name(self):
+ tmpl = self._create_template()
+ button = self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "{{2}}",
+ "button_type": "url",
+ "url_type": "dynamic",
+ "website_url": "https://example.com/",
+ "template_id": tmpl.id,
+ }
+ )
+ # the auto-generated placeholder is not named {{1}}
+ with self.assertRaisesRegex(ValidationError, r"can only be"):
+ button.check_variable_ids()
+
+ # ------------------------------------------------------------------
+ # Rendering / value extraction
+ # ------------------------------------------------------------------
+ def test_render_body_message_default_res_ids(self):
+ tmpl = self._create_template(
+ header="Hi {{1}}",
+ body="Name {{1}}",
+ variable_ids=[
+ Command.create(
+ {"name": "{{1}}", "line_type": "header", "field_name": "name"}
+ ),
+ Command.create(
+ {"name": "{{1}}", "line_type": "body", "field_name": "name"}
+ ),
+ ],
+ )
+ rendered = tmpl.with_context(
+ default_res_ids=[self.partner.id]
+ ).render_body_message()
+ self.assertIn(self.partner.name, rendered)
+
+ def test_extract_value_from_field_path_variants(self):
+ parent = self.env["res.partner"].create({"name": "Parent Co"})
+ self.partner.parent_id = parent
+ tmpl = self._create_template(
+ body="{{1}}",
+ variable_ids=[
+ Command.create(
+ {"name": "{{1}}", "line_type": "body", "field_name": "parent_id"}
+ ),
+ ],
+ )
+ var = tmpl.variable_ids
+ # relational field -> display name of the related record
+ self.assertEqual(
+ var._get_variables_value(self.partner), {"body-{{1}}": "Parent Co"}
+ )
+ # chained path -> last model + last field name
+ var.field_name = "parent_id.name"
+ self.assertEqual(var._extract_value_from_field_path(self.partner), "Parent Co")
+ # selection field -> exported label
+ var.field_name = "type"
+ self.assertTrue(var._extract_value_from_field_path(self.partner))
+
+ def test_variable_display_name_and_onchange(self):
+ tmpl = self._create_template()
+ Variable = self.env["mail.whatsapp.template.variable"]
+ # display name: header variant vs body variant
+ header_var = Variable.new(
+ {"name": "{{1}}", "line_type": "header", "template_id": tmpl.id}
+ )
+ body_var = Variable.new(
+ {"name": "{{1}}", "line_type": "body", "template_id": tmpl.id}
+ )
+ self.assertTrue(header_var.display_name)
+ self.assertIn("{{1}}", body_var.display_name)
+ # onchange resets the field on an in-memory record (no constraint flush)
+ onchange_var = Variable.new(
+ {
+ "name": "{{1}}",
+ "line_type": "body",
+ "template_id": tmpl.id,
+ "field_name": "name",
+ }
+ )
+ onchange_var._onchange_model_id()
+ self.assertFalse(onchange_var.field_name)
+
+ def test_button_back2draft(self):
+ tmpl = self._create_template()
+ tmpl.button_back2draft()
+ self.assertEqual(tmpl.state, "draft")
+
+ # ------------------------------------------------------------------
+ # discuss.channel avatar
+ # ------------------------------------------------------------------
+ def test_generate_avatar_gateway_whatsapp(self):
+ channel = self.partner._whatsapp_get_channel("phone", self.gateway)
+ avatar = channel._generate_avatar_gateway()
+ self.assertIn(" no channel, skipped
+ {
+ "recipient_id": "0000",
+ "id": "wamid.TEST",
+ "status": "failed",
+ },
+ # failed status without a matching notification
+ {
+ "recipient_id": token,
+ "id": "wamid.UNKNOWN",
+ "status": "failed",
+ },
+ # failed status updating our notification
+ {
+ "recipient_id": token,
+ "id": "wamid.TEST",
+ "status": "failed",
+ "errors": [
+ {
+ "code": 131,
+ "error_data": {"details": "Boom"},
+ }
+ ],
+ },
+ ]
+ },
+ }
+ ]
+ }
+ ]
+ }
+ service._receive_update(self.gateway, update)
+ self.assertEqual(notification.notification_status, "exception")
+ self.assertIn("Boom", notification.failure_reason)
+
+ def test_receive_update_status_delivery(self):
+ service = self.env["mail.gateway.whatsapp"]
+ channel = self.partner._whatsapp_get_channel("phone", self.gateway)
+ token = channel.gateway_channel_token
+ channel_message = channel.with_context(
+ no_gateway_notification=True
+ ).message_post(
+ body="hi", message_type="comment", subtype_xmlid="mail.mt_comment"
+ )
+ document_message = self.partner.message_post(
+ body="hi", message_type="comment", subtype_xmlid="mail.mt_comment"
+ )
+ notifications = self.env["mail.notification"]
+ # The channel and the document notifications share the same wamid
+ for message in channel_message | document_message:
+ notifications |= self.env["mail.notification"].create(
+ {
+ "mail_message_id": message.id,
+ "notification_type": "gateway",
+ "notification_status": "sent",
+ "gateway_channel_id": channel.id,
+ "gateway_message_id": "wamid.TEST",
+ "res_partner_id": self.partner.id,
+ }
+ )
+
+ def receive(status):
+ service._receive_update(
+ self.gateway,
+ {
+ "entry": [
+ {
+ "changes": [
+ {
+ "field": "messages",
+ "value": {
+ "statuses": [
+ {
+ "recipient_id": token,
+ "id": "wamid.TEST",
+ "status": status,
+ }
+ ]
+ },
+ }
+ ]
+ }
+ ]
+ },
+ )
+
+ receive("sent")
+ self.assertEqual(set(notifications.mapped("whatsapp_status")), {"sent"})
+ receive("delivered")
+ self.assertEqual(set(notifications.mapped("whatsapp_status")), {"delivered"})
+ receive("read")
+ self.assertEqual(set(notifications.mapped("whatsapp_status")), {"read"})
+ # Webhooks may arrive out of order: a late status doesn't go back
+ receive("delivered")
+ self.assertEqual(set(notifications.mapped("whatsapp_status")), {"read"})
+ # Unknown statuses are ignored
+ receive("deleted")
+ self.assertEqual(set(notifications.mapped("whatsapp_status")), {"read"})
+ self.assertEqual(set(notifications.mapped("notification_status")), {"sent"})
+
+ def test_receive_update_image_and_location(self):
+ service = self.env["mail.gateway.whatsapp"]
+ token = self.partner.phone.replace("+", "").replace(" ", "")
+
+ def fake_get(url, *args, **kwargs):
+ response = requests.Response()
+ response.status_code = 200
+ if "cdn" in url:
+ response._content = b"IMAGEBYTES"
+ else:
+ response._content = json.dumps(
+ {"url": "https://cdn.example.com/img", "mime_type": "image/png"}
+ ).encode()
+ return response
+
+ value = {
+ "messaging_product": "whatsapp",
+ "contacts": [{"wa_id": token, "profile": {"name": "Ada"}}],
+ "messages": [
+ {
+ "from": token,
+ "id": "wamid.IMG",
+ "timestamp": "1700000000",
+ "type": "image",
+ "image": {"id": "IMG_ID", "caption": "Nice pic"},
+ },
+ {
+ "from": token,
+ "id": "wamid.LOC",
+ "timestamp": "1700000001",
+ "type": "location",
+ "location": {"latitude": "1.23", "longitude": "4.56"},
+ },
+ ],
+ }
+ update = {"entry": [{"changes": [{"field": "messages", "value": value}]}]}
+ # The controller processes inbound updates with ``no_gateway_notification``
+ # so incoming messages are not echoed back to the Meta API.
+ gateway = self.gateway.with_context(no_gateway_notification=True)
+ with patch.object(requests, "get", fake_get):
+ service._receive_update(gateway, update)
+ channel = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+ self.assertTrue(channel.message_ids)
+ self.assertTrue(channel.message_ids.attachment_ids)
+
+ def test_receive_update_ignores_other_fields(self):
+ service = self.env["mail.gateway.whatsapp"]
+ token = self.partner.phone.replace("+", "")
+ value = {
+ "messages": [
+ {
+ "from": token,
+ "id": "wamid.OTHER",
+ "timestamp": "1700000000",
+ "text": {"body": "ignored"},
+ }
+ ]
+ }
+ update = {"entry": [{"changes": [{"field": "account_update", "value": value}]}]}
+ service._receive_update(self.gateway, update)
+ self.assertFalse(
+ self.env["discuss.channel"].search([("gateway_id", "=", self.gateway.id)])
+ )
+
+ def test_send_payload_document(self):
+ service = self.env["mail.gateway.whatsapp"]
+ channel = self.partner._whatsapp_get_channel("phone", self.gateway)
+ payload = service._send_payload(
+ channel,
+ media_id="MEDIA_ID",
+ media_type="document",
+ media_name="invoice.pdf",
+ )
+ self.assertEqual(payload["type"], "document")
+ self.assertEqual(
+ payload["document"], {"id": "MEDIA_ID", "filename": "invoice.pdf"}
+ )
+ payload = service._send_payload(
+ channel, media_id="MEDIA_ID", media_type="image"
+ )
+ self.assertEqual(payload["image"], {"id": "MEDIA_ID"})
+
+ # ------------------------------------------------------------------
+ # mail.thread helpers
+ # ------------------------------------------------------------------
+ def test_get_whatsapp_channel_vals(self):
+ Partner = self.env["res.partner"]
+ vals = Partner._get_whatsapp_channel_vals("3412345", self.gateway, self.partner)
+ self.assertEqual(vals["gateway_channel_token"], "3412345")
+ self.assertEqual(vals["partner_id"], self.partner.id)
+ vals_no_partner = Partner._get_whatsapp_channel_vals(
+ "3412345", self.gateway, Partner.browse()
+ )
+ self.assertNotIn("partner_id", vals_no_partner)
+
+ def test_whatsapp_get_channel_creates_gateway_channel(self):
+ channel = self.partner._whatsapp_get_channel("phone", self.gateway)
+ self.assertTrue(channel)
+ gateway_channel = self.env["res.partner.gateway.channel"].search(
+ [
+ ("partner_id", "=", self.partner.id),
+ ("gateway_id", "=", self.gateway.id),
+ ]
+ )
+ self.assertTrue(gateway_channel)
+ # a second call reuses the existing gateway channel
+ self.partner._whatsapp_get_channel("phone", self.gateway)
+ self.assertEqual(
+ self.env["res.partner.gateway.channel"].search_count(
+ [
+ ("partner_id", "=", self.partner.id),
+ ("gateway_id", "=", self.gateway.id),
+ ]
+ ),
+ 1,
+ )
+
+ def test_whatsapp_get_partner_without_partner_field(self):
+ self.assertIsNone(self.env["mail.blacklist"]._whatsapp_get_partner())
+
+ def test_whatsapp_get_channel_no_phone(self):
+ partner = self.env["res.partner"].create({"name": "No Phone"})
+ with self.assertRaisesRegex(UserError, "Phone cannot be sanitized"):
+ partner._whatsapp_get_channel("phone", self.gateway)
+
+ # ------------------------------------------------------------------
+ # ir.actions.server
+ # ------------------------------------------------------------------
+ def test_server_action_model_coherency(self):
+ with self.assertRaisesRegex(ValidationError, "non transient mail.thread"):
+ self.env["ir.actions.server"].create(
+ {
+ "name": "WA transient",
+ "model_id": self.env["ir.model"]._get_id("res.users.settings"),
+ "state": "whatsapp",
+ "whatsapp_gateway_id": self.gateway.id,
+ }
+ )
+
+ def test_server_action_run_whatsapp(self):
+ tmpl = self._create_template(
+ model_id=self.env["ir.model"]._get_id("res.partner"),
+ )
+ action = self.env["ir.actions.server"].create(
+ {
+ "name": "WA action",
+ "model_id": self.env["ir.model"]._get_id("res.partner"),
+ "state": "whatsapp",
+ "whatsapp_gateway_id": self.gateway.id,
+ "whatsapp_partner": "{{ object.id }}",
+ "whatsapp_template_id": tmpl.id,
+ }
+ )
+ self.assertTrue(action.available_model_ids)
+ service_cls = type(self.env["mail.gateway.whatsapp"])
+ with patch.object(service_cls, "_send", return_value=None):
+ action.with_context(
+ active_model="res.partner",
+ active_ids=self.partner.ids,
+ active_id=self.partner.id,
+ ).run()
+ channel = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+ self.assertTrue(channel)
+
+ def test_server_action_run_whatsapp_no_active_ids(self):
+ tmpl = self._create_template(
+ model_id=self.env["ir.model"]._get_id("res.partner"),
+ )
+ action = self.env["ir.actions.server"].create(
+ {
+ "name": "WA action noctx",
+ "model_id": self.env["ir.model"]._get_id("res.partner"),
+ "state": "whatsapp",
+ "whatsapp_gateway_id": self.gateway.id,
+ "whatsapp_partner": "{{ object.id }}",
+ "whatsapp_template_id": tmpl.id,
+ }
+ )
+ # No active_ids/active_id -> action does nothing
+ self.assertFalse(action._run_action_whatsapp_multi())
+
+ # ------------------------------------------------------------------
+ # Wizards
+ # ------------------------------------------------------------------
+ def test_whatsapp_composer(self):
+ composer = (
+ self.env["whatsapp.composer"]
+ .with_context(
+ active_model="res.partner",
+ active_id=self.partner.id,
+ )
+ .create(
+ {
+ "res_model": "res.partner",
+ "res_id": self.partner.id,
+ "number_field_name": "phone",
+ "gateway_id": self.gateway.id,
+ }
+ )
+ )
+ # is_required_template computed with no previous message -> True
+ self.assertTrue(composer.is_required_template)
+ composer.onchange_gateway_id()
+ self.assertFalse(composer.template_id)
+ # Sending without body raises
+ with self.assertRaisesRegex(UserError, "Body is required"):
+ composer.action_send_whatsapp()
+ # With body -> posts a message on the channel
+ composer.body = "Hello"
+ service_cls = type(self.env["mail.gateway.whatsapp"])
+ with patch.object(service_cls, "_send", return_value=None):
+ composer.action_send_whatsapp()
+ action = composer.action_view_whatsapp()
+ self.assertEqual(action["tag"], "mail.action_discuss")
+
+ def test_whatsapp_composer_missing_record(self):
+ composer = self.env["whatsapp.composer"].create(
+ {
+ "res_model": "res.partner",
+ "gateway_id": self.gateway.id,
+ "body": "Hello",
+ }
+ )
+ self.assertFalse(composer.action_view_whatsapp())
+ self.assertFalse(composer.action_send_whatsapp())
+ self.assertFalse(
+ self.env["discuss.channel"].search([("gateway_id", "=", self.gateway.id)])
+ )
+
+ def test_whatsapp_composer_not_required_template(self):
+ # Missing number_field_name -> template is not required
+ composer = self.env["whatsapp.composer"].create(
+ {
+ "res_model": "res.partner",
+ "res_id": self.partner.id,
+ "gateway_id": self.gateway.id,
+ }
+ )
+ self.assertFalse(composer.is_required_template)
+
+ def test_whatsapp_composer_default_get_multiple(self):
+ # A second whatsapp gateway -> the wizard cannot auto-pick one
+ self.env["mail.gateway"].create(
+ {
+ "name": "gateway 2",
+ "gateway_type": "whatsapp",
+ "token": "token2",
+ "whatsapp_security_key": "key2",
+ "webhook_secret": "SECRET-2",
+ }
+ )
+ defaults = self.env["whatsapp.composer"].default_get(
+ ["find_gateway", "gateway_id"]
+ )
+ self.assertTrue(defaults["find_gateway"])
+
+ def test_prepare_value_to_send_res_id_context(self):
+ tmpl = self._create_template(
+ body="Hi {{1}}",
+ model_id=self.env["ir.model"]._get_id("res.partner"),
+ variable_ids=[
+ Command.create(
+ {"name": "{{1}}", "line_type": "body", "field_name": "name"}
+ ),
+ ],
+ )
+ # prepare_value_to_send resolves the record from the ``res_id`` context
+ components = tmpl.with_context(res_id=self.partner.id).prepare_value_to_send()
+ body = next(c for c in components if c["type"] == "body")
+ self.assertEqual(body["parameters"][0]["text"], self.partner.name)
+
+ def test_whatsapp_composer_default_get(self):
+ defaults = self.env["whatsapp.composer"].default_get(
+ ["find_gateway", "gateway_id"]
+ )
+ # Only one whatsapp gateway exists -> auto-selected
+ self.assertFalse(defaults["find_gateway"])
+ self.assertEqual(defaults["gateway_id"], self.gateway.id)
+
+ def test_compose_gateway_message_template(self):
+ tmpl = self._create_template(
+ body="Hello world",
+ model_id=self.env["ir.model"]._get_id("res.partner"),
+ )
+ composer = self.env["mail.compose.gateway.message"].create(
+ {
+ "model": "res.partner",
+ "res_ids": f"[{self.partner.id}]",
+ "whatsapp_template_id": tmpl.id,
+ }
+ )
+ composer.onchange_whatsapp_template_id()
+ self.assertTrue(composer.body)
+ # sending applies the whatsapp template context branch
+ service_cls = type(self.env["mail.gateway.whatsapp"])
+ with patch.object(service_cls, "_send", return_value=None):
+ composer._action_send_mail()
+
+ # ------------------------------------------------------------------
+ # Inbound edge cases
+ # ------------------------------------------------------------------
+ def _new_message_value(self, token, **message):
+ message_vals = {
+ "from": token,
+ "id": "wamid.IN",
+ "timestamp": "1700000000",
+ "text": {"body": "Hello"},
+ }
+ message_vals.update(message)
+ return {
+ "messaging_product": "whatsapp",
+ "contacts": [{"wa_id": token, "profile": {"name": "Ada"}}],
+ "messages": [message_vals],
+ }
+
+ def test_receive_update_empty_and_no_channel(self):
+ service = self.env["mail.gateway.whatsapp"]
+ service._receive_update(self.gateway, {})
+ value = self._new_message_value("34900111333")
+ update = {"entry": [{"changes": [{"field": "messages", "value": value}]}]}
+ with patch.object(type(service), "_get_channel", return_value=False):
+ service._receive_update(self.gateway, update)
+ self.assertFalse(
+ self.env["discuss.channel"].search([("gateway_id", "=", self.gateway.id)])
+ )
+
+ def test_receive_update_media_without_url_and_contacts(self):
+ service = self.env["mail.gateway.whatsapp"]
+ value = self._new_message_value(
+ "34900111333",
+ sticker={"mime_type": "image/webp"},
+ contacts=[{"name": {"formatted_name": "Bob"}}],
+ )
+ update = {"entry": [{"changes": [{"field": "messages", "value": value}]}]}
+ gateway = self.gateway.with_context(no_gateway_notification=True)
+ service._receive_update(gateway, update)
+ channel = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+ self.assertEqual(len(channel.message_ids), 1)
+ self.assertFalse(channel.message_ids.attachment_ids)
+
+ def test_receive_update_reply_to_record_message(self):
+ service = self.env["mail.gateway.whatsapp"]
+ channel = self.partner._whatsapp_get_channel("phone", self.gateway)
+ token = channel.gateway_channel_token
+ sent = channel.with_context(no_gateway_notification=True).message_post(
+ body="Question", message_type="comment", subtype_xmlid="mail.mt_comment"
+ )
+ record_message = self.partner.message_post(
+ body="Question", message_type="comment", subtype_xmlid="mail.mt_comment"
+ )
+ sent.gateway_message_id = record_message
+ self.env["mail.notification"].create(
+ {
+ "mail_message_id": sent.id,
+ "notification_type": "gateway",
+ "gateway_channel_id": channel.id,
+ "gateway_message_id": "wamid.SENT",
+ "res_partner_id": self.partner.id,
+ }
+ )
+ value = self._new_message_value(
+ token, text={"body": "Answer"}, context={"id": "wamid.SENT"}
+ )
+ update = {"entry": [{"changes": [{"field": "messages", "value": value}]}]}
+ gateway = self.gateway.with_context(no_gateway_notification=True)
+ service._receive_update(gateway, update)
+ reply = channel.message_ids.filtered(lambda m: m.parent_id == sent)
+ self.assertTrue(reply)
+ self.assertEqual(reply.gateway_message_id.model, "res.partner")
+ self.assertEqual(reply.gateway_message_id.res_id, self.partner.id)
+ self.assertIn("Answer", reply.gateway_message_id.body)
+
+ def test_get_author_and_channel_vals_without_contact(self):
+ service = self.env["mail.gateway.whatsapp"]
+ update = {
+ "messages": [{"from": "34900111333"}],
+ "contacts": [{"wa_id": "34900111444", "profile": {"name": "Other"}}],
+ }
+ self.assertFalse(service._get_author(self.gateway, update))
+ self.assertFalse(service._get_author(self.gateway, {"messages": [{}]}))
+ vals = service._get_channel_vals(self.gateway, "34900111333", update)
+ self.assertEqual(vals["name"], "34900111333")
+
+ # ------------------------------------------------------------------
+ # Outbound edge cases
+ # ------------------------------------------------------------------
+ def _new_gateway_notification(self):
+ channel = self.partner._whatsapp_get_channel("phone", self.gateway)
+ message = channel.with_context(no_gateway_notification=True).message_post(
+ body="Hello", message_type="comment", subtype_xmlid="mail.mt_comment"
+ )
+ return self.env["mail.notification"].create(
+ {
+ "mail_message_id": message.id,
+ "notification_type": "gateway",
+ "gateway_channel_id": channel.id,
+ "res_partner_id": self.partner.id,
+ }
+ )
+
+ def test_send_raise_exception(self):
+ service = self.env["mail.gateway.whatsapp"]
+ notification = self._new_gateway_notification()
+ with (
+ patch.object(requests, "post", side_effect=requests.ConnectionError),
+ mute_logger(
+ "odoo.addons.mail_gateway_whatsapp.models.mail_gateway_whatsapp"
+ ),
+ self.assertRaises(MailDeliveryException),
+ ):
+ service._send(self.gateway, notification, raise_exception=True)
+
+ def test_send_auto_commit(self):
+ service = self.env["mail.gateway.whatsapp"]
+ notification = self._new_gateway_notification()
+ response = MagicMock()
+ response.json.return_value = {"messages": [{"id": "wamid.OUT"}]}
+ with (
+ patch.object(requests, "post", return_value=response),
+ patch.object(self.env.cr, "commit") as commit_mock,
+ ):
+ service._send(self.gateway, notification, auto_commit=True)
+ commit_mock.assert_called_once()
+ self.assertEqual(notification.notification_status, "sent")
+ self.assertEqual(notification.gateway_message_id, "wamid.OUT")
+
+ # ------------------------------------------------------------------
+ # Template variables and buttons
+ # ------------------------------------------------------------------
+ def test_header_variable_lifecycle(self):
+ tmpl = self._create_template(header="Hello {{1}}")
+ header_variable = tmpl.variable_ids.filtered(
+ lambda var: var.line_type == "header"
+ )
+ self.assertEqual(header_variable.name, "{{1}}")
+ # Keep the variable while the header still uses it
+ tmpl.header = "Hi {{1}}"
+ self.assertEqual(
+ tmpl.variable_ids.filtered(lambda var: var.line_type == "header"),
+ header_variable,
+ )
+ # Drop it once the header has no variable anymore
+ tmpl.header = "Hi"
+ self.assertFalse(
+ tmpl.variable_ids.filtered(lambda var: var.line_type == "header")
+ )
+
+ def test_prepare_components_to_export_static_and_quick_reply(self):
+ tmpl = self._create_template()
+ self.env["mail.whatsapp.template.button"].create(
+ [
+ {
+ "name": "Site",
+ "button_type": "url",
+ "url_type": "static",
+ "website_url": "https://example.com",
+ "template_id": tmpl.id,
+ },
+ {
+ "name": "Yes",
+ "button_type": "quick_reply",
+ "template_id": tmpl.id,
+ },
+ ]
+ )
+ components = tmpl._prepare_components_to_export()
+ buttons = next(c for c in components if c["type"] == "BUTTONS")["buttons"]
+ self.assertEqual(
+ buttons,
+ [
+ {"type": "URL", "text": "Site", "url": "https://example.com"},
+ {"type": "QUICK_REPLY", "text": "Yes"},
+ ],
+ )
+
+ def test_extract_value_without_field_name(self):
+ tmpl = self._create_template(body="{{1}}")
+ self.assertEqual(
+ tmpl.variable_ids._extract_value_from_field_path(self.partner), ""
+ )
+
+ def test_button_dynamic_variable_update_and_checks(self):
+ tmpl = self._create_template()
+ button = self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "{{1}}",
+ "button_type": "url",
+ "url_type": "dynamic",
+ "website_url": "https://example.com/",
+ "template_id": tmpl.id,
+ }
+ )
+ variable = button.variable_ids
+ # Changing the url updates the existing placeholder
+ button.website_url = "https://example.org/"
+ self.assertEqual(button.variable_ids, variable)
+ self.assertEqual(variable.sample_value, "https://example.org/")
+ # More than one placeholder is invalid
+ button.variable_ids = [
+ Command.create(
+ {"name": "{{2}}", "line_type": "button", "template_id": tmpl.id}
+ )
+ ]
+ with self.assertRaisesRegex(ValidationError, "only contain one placeholder"):
+ button.check_variable_ids()
+ # A dynamic url without placeholder is invalid
+ button.variable_ids = [Command.clear()]
+ with self.assertRaisesRegex(ValidationError, "must have a placeholder"):
+ button.check_variable_ids()
+
+ def test_receive_update_media_with_url(self):
+ service = self.env["mail.gateway.whatsapp"]
+ value = self._new_message_value(
+ "34900111333",
+ document={
+ "url": "https://cdn.example.com/doc",
+ "mime_type": "application/pdf",
+ "caption": "Invoice",
+ },
+ )
+ update = {"entry": [{"changes": [{"field": "messages", "value": value}]}]}
+ response = requests.Response()
+ response.status_code = 200
+ response._content = b"PDFBYTES"
+ gateway = self.gateway.with_context(no_gateway_notification=True)
+ with patch.object(requests, "get", return_value=response) as get_mock:
+ service._receive_update(gateway, update)
+ get_mock.assert_called_once()
+ channel = self.env["discuss.channel"].search(
+ [("gateway_id", "=", self.gateway.id)]
+ )
+ attachment = channel.message_ids.attachment_ids
+ self.assertEqual(attachment.name, "document.pdf")
+ self.assertEqual(attachment.raw, b"PDFBYTES")
+
+ def test_template_values_without_record(self):
+ tmpl = self._create_template(
+ body="Name {{1}}",
+ model_id=self.env["ir.model"]._get_id("res.partner"),
+ variable_ids=[
+ Command.create(
+ {"name": "{{1}}", "line_type": "body", "field_name": "name"}
+ ),
+ ],
+ )
+ components = tmpl.prepare_value_to_send()
+ self.assertEqual(
+ components,
+ [{"type": "body", "parameters": [{"type": "text", "text": " "}]}],
+ )
+ self.assertEqual(tmpl.render_body_message(), "Name ")
diff --git a/mail_gateway_whatsapp/tests/test_mail_whatsapp_template.py b/mail_gateway_whatsapp/tests/test_mail_whatsapp_template.py
new file mode 100644
index 000000000..87b3a9be0
--- /dev/null
+++ b/mail_gateway_whatsapp/tests/test_mail_whatsapp_template.py
@@ -0,0 +1,240 @@
+# Copyright 2024 Tecnativa - Carlos López
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+import json
+from unittest.mock import patch
+
+import requests
+
+from odoo import Command
+from odoo.exceptions import UserError
+from odoo.tests.common import tagged
+
+from odoo.addons.mail_gateway.tests.common import MailGatewayTestCase
+
+
+@tagged("-at_install", "post_install")
+class TestMailWhatsAppTemplate(MailGatewayTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.gateway = cls.env["mail.gateway"].create(
+ {
+ "name": "gateway",
+ "gateway_type": "whatsapp",
+ "token": "token",
+ "whatsapp_security_key": "key",
+ "webhook_secret": "MY-SECRET",
+ "member_ids": [(4, cls.env.user.id)],
+ }
+ )
+ cls.new_template_response_data = {
+ "id": "018273645",
+ "status": "APPROVED",
+ }
+ cls.new_template_full_response_data = {
+ "name": "new_template",
+ "parameter_format": "POSITIONAL",
+ "components": [
+ {"type": "HEADER", "format": "TEXT", "text": "Header 1"},
+ {"type": "BODY", "text": "Body 1"},
+ {"type": "FOOTER", "text": "Footer changed"},
+ ],
+ "language": "es",
+ "status": "APPROVED",
+ "category": "MARKETING",
+ "id": "018273645",
+ }
+ cls.template_1_data = {
+ "name": "test_odoo_1",
+ "parameter_format": "POSITIONAL",
+ "components": [
+ {"type": "HEADER", "format": "TEXT", "text": "Header 1"},
+ {"type": "BODY", "text": "Body 1"},
+ {"type": "FOOTER", "text": "Footer 1"},
+ ],
+ "language": "es",
+ "status": "APPROVED",
+ "category": "MARKETING",
+ "id": "1234567890",
+ }
+ cls.template_2_data = {
+ "name": "test_with_buttons",
+ "parameter_format": "POSITIONAL",
+ "components": [
+ {"type": "HEADER", "format": "TEXT", "text": "Header 2"},
+ {"type": "BODY", "text": "Body 2"},
+ {
+ "type": "BUTTONS",
+ "buttons": [{"type": "QUICK_REPLY", "text": "Button 1"}],
+ },
+ ],
+ "language": "es",
+ "status": "APPROVED",
+ "category": "MARKETING",
+ "sub_category": "CUSTOM",
+ "id": "0987654321",
+ }
+ cls.templates_download = {
+ "data": [cls.template_1_data, cls.template_2_data],
+ }
+
+ def _make_meta_requests(self, url, json_data, status_code=200):
+ """
+ Simulate a fake request to the Meta API:
+ :param json_data: Dictionary with the json data to return
+ :param status_code: Status code expected
+ :returns requests.Response object
+ """
+ response = requests.Response()
+ response.status_code = status_code
+ response._content = json.dumps(json_data).encode()
+ response.url = url
+ response.headers["Content-Type"] = "application/json"
+ return response
+
+ def test_download_templates(self):
+ def _patch_request_post(url, *args, **kwargs):
+ if "message_templates" in url:
+ return self._make_meta_requests(url, self.templates_download)
+ return original_get(url, *args, **kwargs)
+
+ with self.assertRaisesRegex(
+ UserError, "WhatsApp Account is required to import templates"
+ ):
+ self.gateway.button_import_whatsapp_template()
+ self.gateway.whatsapp_account_id = "123456"
+ original_get = requests.get
+ with patch.object(requests, "get", _patch_request_post):
+ self.gateway.button_import_whatsapp_template()
+ self.assertEqual(self.gateway.whatsapp_template_count, 2)
+ template_1 = self.gateway.whatsapp_template_ids.filtered(
+ lambda t: t.template_uid == "1234567890"
+ )
+ self.assertTrue(template_1.is_supported)
+ self.assertEqual(template_1.template_name, "test_odoo_1")
+ self.assertEqual(template_1.category, "marketing")
+ self.assertEqual(template_1.language, "es")
+ self.assertEqual(template_1.state, "approved")
+ self.assertEqual(template_1.header, "Header 1")
+ self.assertEqual(template_1.body, "Body 1")
+ self.assertEqual(template_1.footer, "Footer 1")
+ template_2 = self.gateway.whatsapp_template_ids.filtered(
+ lambda t: t.template_uid == "0987654321"
+ )
+ self.assertTrue(template_2.is_supported)
+ self.assertEqual(template_2.template_name, "test_with_buttons")
+ self.assertEqual(template_2.category, "marketing")
+ self.assertEqual(template_2.language, "es")
+ self.assertEqual(template_2.state, "approved")
+ self.assertEqual(template_2.header, "Header 2")
+ self.assertEqual(template_2.body, "Body 2")
+ self.assertFalse(template_2.footer)
+
+ def test_export_template(self):
+ def _patch_request_post(url, *args, **kwargs):
+ if "message_templates" in url:
+ return self._make_meta_requests(url, self.new_template_response_data)
+ return original_post(url, *args, **kwargs)
+
+ def _patch_request_get(url, *args, **kwargs):
+ if "018273645" in url:
+ return self._make_meta_requests(
+ url, self.new_template_full_response_data
+ )
+ return original_get(url, *args, **kwargs)
+
+ original_post = requests.post
+ original_get = requests.get
+ self.gateway.whatsapp_account_id = "123456"
+ new_template = self.env["mail.whatsapp.template"].create(
+ {
+ "name": "New template",
+ "category": "marketing",
+ "language": "es",
+ "header": "Header 1",
+ "body": "Body 1",
+ "gateway_id": self.gateway.id,
+ }
+ )
+ self.assertEqual(new_template.template_name, "new_template")
+ with patch.object(requests, "post", _patch_request_post):
+ new_template.button_export_template()
+ self.assertTrue(new_template.template_uid)
+ self.assertTrue(new_template.is_supported)
+ self.assertFalse(new_template.footer)
+ self.assertEqual(new_template.state, "approved")
+ # sync templates, footer should be updated
+ with patch.object(requests, "get", _patch_request_get):
+ new_template.button_sync_template()
+ self.assertEqual(new_template.footer, "Footer changed")
+
+ def test_prepare_values_template_send(self):
+ partner = self.env["res.partner"].create(
+ {
+ "name": "Ada Lovelace",
+ "phone": "+34900111222",
+ }
+ )
+ ctx = {
+ "default_res_model": partner._name,
+ "default_res_id": partner.id,
+ }
+ tmpl = self.env["mail.whatsapp.template"].create(
+ {
+ "name": "Test Render",
+ "category": "utility",
+ "language": "es",
+ "header": "Hi {{1}}",
+ "body": "Name: {{1}} · Tel: {{2}}",
+ "gateway_id": self.gateway.id,
+ "variable_ids": [Command.clear()],
+ "state": "approved",
+ "is_supported": True,
+ "model_id": self.env["ir.model"]._get("res.partner").id,
+ }
+ )
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{1}}",
+ "line_type": "header",
+ "template_id": tmpl.id,
+ "field_name": "name",
+ }
+ )
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{1}}",
+ "line_type": "body",
+ "template_id": tmpl.id,
+ "field_name": "name",
+ }
+ )
+ self.env["mail.whatsapp.template.variable"].create(
+ {
+ "name": "{{2}}",
+ "line_type": "body",
+ "template_id": tmpl.id,
+ "field_name": "phone",
+ }
+ )
+ self.env["mail.whatsapp.template.button"].create(
+ {
+ "name": "phone",
+ "button_type": "phone_number",
+ "template_id": tmpl.id,
+ "call_number": "+34666555444",
+ }
+ )
+ components = tmpl.with_context(**ctx).prepare_value_to_send()
+
+ header = next(c for c in components if c["type"].upper() == "HEADER")
+ self.assertEqual([p["type"] for p in header.get("parameters", [])], ["text"])
+ self.assertEqual(header["parameters"][0]["text"], partner.name)
+
+ body = next(c for c in components if c["type"].upper() == "BODY")
+ self.assertEqual(
+ [p["type"] for p in body.get("parameters", [])], ["text", "text"]
+ )
+ self.assertEqual(body["parameters"][0]["text"], partner.name)
+ self.assertEqual(body["parameters"][1]["text"], partner.phone or "")
diff --git a/mail_gateway_whatsapp/tools/__init__.py b/mail_gateway_whatsapp/tools/__init__.py
new file mode 100644
index 000000000..4c73b5fa5
--- /dev/null
+++ b/mail_gateway_whatsapp/tools/__init__.py
@@ -0,0 +1 @@
+from . import const
diff --git a/mail_gateway_whatsapp/tools/const.py b/mail_gateway_whatsapp/tools/const.py
new file mode 100644
index 000000000..b02ee7202
--- /dev/null
+++ b/mail_gateway_whatsapp/tools/const.py
@@ -0,0 +1,77 @@
+# https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/supported-languages # noqa: B501 E501
+# res.lang not matching with supported languages(iso codes)
+supported_languages = [
+ ("af", "Afrikaans"),
+ ("sq", "Albanian"),
+ ("ar", "Arabic"),
+ ("az", "Azerbaijani"),
+ ("bn", "Bengali"),
+ ("bg", "Bulgarian"),
+ ("ca", "Catalan"),
+ ("zh_CN", "Chinese (CHN)"),
+ ("zh_HK", "Chinese (HKG)"),
+ ("zh_TW", "Chinese (TAI)"),
+ ("hr", "Croatian"),
+ ("cs", "Czech"),
+ ("da", "Danish"),
+ ("nl", "Dutch"),
+ ("en", "English"),
+ ("en_GB", "English (UK)"),
+ ("en_US", "English (US)"),
+ ("et", "Estonian"),
+ ("fil", "Filipino"),
+ ("fi", "Finnish"),
+ ("fr", "French"),
+ ("ka", "Georgian"),
+ ("de", "German"),
+ ("el", "Greek"),
+ ("gu", "Gujarati"),
+ ("ha", "Hausa"),
+ ("he", "Hebrew"),
+ ("hi", "Hindi"),
+ ("hu", "Hungarian"),
+ ("id", "Indonesian"),
+ ("ga", "Irish"),
+ ("it", "Italian"),
+ ("ja", "Japanese"),
+ ("kn", "Kannada"),
+ ("kk", "Kazakh"),
+ ("rw_RW", "Kinyarwanda"),
+ ("ko", "Korean"),
+ ("ky_KG", "Kyrgyz (Kyrgyzstan)"),
+ ("lo", "Lao"),
+ ("lv", "Latvian"),
+ ("lt", "Lithuanian"),
+ ("mk", "Macedonian"),
+ ("ms", "Malay"),
+ ("ml", "Malayalam"),
+ ("mr", "Marathi"),
+ ("nb", "Norwegian"),
+ ("fa", "Persian"),
+ ("pl", "Polish"),
+ ("pt_BR", "Portuguese (BR)"),
+ ("pt_PT", "Portuguese (POR)"),
+ ("pa", "Punjabi"),
+ ("ro", "Romanian"),
+ ("ru", "Russian"),
+ ("sr", "Serbian"),
+ ("sk", "Slovak"),
+ ("sl", "Slovenian"),
+ ("es", "Spanish"),
+ ("es_AR", "Spanish (ARG)"),
+ ("es_ES", "Spanish (SPA)"),
+ ("es_MX", "Spanish (MEX)"),
+ ("sw", "Swahili"),
+ ("sv", "Swedish"),
+ ("ta", "Tamil"),
+ ("te", "Telugu"),
+ ("th", "Thai"),
+ ("tr", "Turkish"),
+ ("uk", "Ukrainian"),
+ ("ur", "Urdu"),
+ ("uz", "Uzbek"),
+ ("vi", "Vietnamese"),
+ ("zu", "Zulu"),
+]
+
+REG_VARIABLE = r"{{[1-9][0-9]*}}"
diff --git a/mail_gateway_whatsapp/views/ir_actions_server_views.xml b/mail_gateway_whatsapp/views/ir_actions_server_views.xml
new file mode 100644
index 000000000..7149a5eae
--- /dev/null
+++ b/mail_gateway_whatsapp/views/ir_actions_server_views.xml
@@ -0,0 +1,33 @@
+
+
+
+ ir.actions.server.view.form.inherit.mail_gateway_whatsapp
+ ir.actions.server
+
+
+
+
+
+
+
+
+
+
diff --git a/mail_gateway_whatsapp/views/mail_gateway.xml b/mail_gateway_whatsapp/views/mail_gateway.xml
new file mode 100644
index 000000000..bbb820808
--- /dev/null
+++ b/mail_gateway_whatsapp/views/mail_gateway.xml
@@ -0,0 +1,127 @@
+
+
+
+
+ mail.gateway.form (in mail_gateway_telegram)
+ mail.gateway
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
First steps
+
Define the values of the fields Webhook Key and Whatsapp Security Key .
+ You should set some random value of your choice for this fields.
+ Ensure that facebook will be able to comunicate with your server.
+
Creating the Application from meta developer
+
+ Access
+
+ Meta developer platform .
+ Create a new application.
+ Select the Other option, then Business in order to create Whatsapp manager
+ Select a name and create it
+ Select the Whatsapp API on the products of the application
+ Select the business that you will use
+ Go to Whatsapp / API Configuration menú.
+ Add a new number.
+ Copy the phone identification number to the field Whastapp From phone field.
+ Access the menu Configuration / Basic information .
+ Show the secret key of the application and copy it to the Webhook secret field.
+ Access the menu Configuration / Advanced Options .
+ Copy the API Version on the Whastapp Version field.
+
+
Creating a permanent token
+
+ Access your meta business site.
+ Access your business settings menu
+ Go to Users / System Users and create a new one with administrator
+ Once it is created, Generate a new token related to the created app with no expiry and give access to all whatsapp permissions.
+ Copy the token value to token field in this page.
+
+
Setting webhook and version
+
+ Save this record. All the values have been filled
+ Press the button Integrate webhook.
+ On Facebook Develpment, return to the app menu and go to Whatsapp / Configuration .
+ Click on Edit Button on the webhook area.
+ On the wizard, fill the URL field with Webhook URL field of the gateway. For the verification identifier, use the Whatsapp Security Key field.
+ Verify and save the wizard.
+ If no error is raised, refresh this gateway data, and you should see that it is integrated.
+ You should be able to receive and send messages.
+ If an error is raised, check that the fields are filled properly and that facebook server is able to access your server.
+
+
+
+
+
+
+
diff --git a/mail_gateway_whatsapp/views/mail_whatsapp_template_views.xml b/mail_gateway_whatsapp/views/mail_whatsapp_template_views.xml
new file mode 100644
index 000000000..2f9052a39
--- /dev/null
+++ b/mail_gateway_whatsapp/views/mail_whatsapp_template_views.xml
@@ -0,0 +1,190 @@
+
+
+
+ view.mail.whatsapp.template.tree
+ mail.whatsapp.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ view.mail.whatsapp.template.form
+ mail.whatsapp.template
+
+
+
+
+
+
+ view.mail.whatsapp.template.search
+ mail.whatsapp.template
+
+
+
+
+
+
+
+
+
+
+ WhatsApp Templates
+ ir.actions.act_window
+ mail.whatsapp.template
+ list,form
+ [('gateway_id', '=', active_id)]
+ {'default_gateway_id': active_id}
+
+
diff --git a/mail_gateway_whatsapp/wizards/__init__.py b/mail_gateway_whatsapp/wizards/__init__.py
new file mode 100644
index 000000000..47359283f
--- /dev/null
+++ b/mail_gateway_whatsapp/wizards/__init__.py
@@ -0,0 +1,2 @@
+from . import mail_compose_gateway_message
+from . import whatsapp_composer
diff --git a/mail_gateway_whatsapp/wizards/mail_compose_gateway_message.py b/mail_gateway_whatsapp/wizards/mail_compose_gateway_message.py
new file mode 100644
index 000000000..cd24a1b09
--- /dev/null
+++ b/mail_gateway_whatsapp/wizards/mail_compose_gateway_message.py
@@ -0,0 +1,34 @@
+# Copyright 2024 Tecnativa - Carlos López
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+import markupsafe
+
+from odoo import api, fields, models
+
+
+class MailComposeGatewayMessage(models.TransientModel):
+ _inherit = "mail.compose.gateway.message"
+
+ whatsapp_template_id = fields.Many2one(
+ "mail.whatsapp.template",
+ domain="""[
+ ('state', '=', 'approved'),
+ ('is_supported', '=', True),
+ ('model', '=', model)
+ ]""",
+ )
+
+ @api.onchange("whatsapp_template_id")
+ def onchange_whatsapp_template_id(self):
+ if self.whatsapp_template_id:
+ self.body = markupsafe.Markup(
+ self.whatsapp_template_id.render_body_message()
+ )
+
+ def _action_send_mail(self, auto_commit=False):
+ if self.whatsapp_template_id:
+ self = self.with_context(
+ whatsapp_template_id=self.whatsapp_template_id.id,
+ res_id=int(self.res_ids.strip("[]")),
+ )
+ return super()._action_send_mail(auto_commit=auto_commit)
diff --git a/mail_gateway_whatsapp/wizards/mail_compose_gateway_message.xml b/mail_gateway_whatsapp/wizards/mail_compose_gateway_message.xml
new file mode 100644
index 000000000..17bb3aa23
--- /dev/null
+++ b/mail_gateway_whatsapp/wizards/mail_compose_gateway_message.xml
@@ -0,0 +1,26 @@
+
+
+
+
+ mail.compose.gateway.message
+
+
+
+
+
+
+ whatsapp_template_id
+
+
+ whatsapp_template_id
+
+
+
+
diff --git a/mail_gateway_whatsapp/wizards/whatsapp_composer.py b/mail_gateway_whatsapp/wizards/whatsapp_composer.py
new file mode 100644
index 000000000..967c7bb7a
--- /dev/null
+++ b/mail_gateway_whatsapp/wizards/whatsapp_composer.py
@@ -0,0 +1,111 @@
+# Copyright 2022 CreuBlanca
+# Copyright 2024 Tecnativa - Carlos López
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from datetime import datetime
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+
+
+class WhatsappComposer(models.TransientModel):
+ _name = "whatsapp.composer"
+ _description = "Compose a whatsapp message"
+
+ res_model = fields.Char("Document Model Name")
+ res_id = fields.Integer("Document ID")
+ number_field_name = fields.Char()
+ find_gateway = fields.Boolean()
+ gateway_id = fields.Many2one(
+ "mail.gateway", domain=[("gateway_type", "=", "whatsapp")], required=True
+ )
+ template_id = fields.Many2one(
+ "mail.whatsapp.template",
+ domain="""[
+ ('gateway_id', '=', gateway_id),
+ ('state', '=', 'approved'),
+ ('is_supported', '=', True),
+ ('model', '=', res_model)
+ ]""",
+ )
+ body = fields.Text("Message")
+ is_required_template = fields.Boolean(compute="_compute_is_required_template")
+
+ @api.depends("res_model", "res_id", "number_field_name", "gateway_id")
+ def _compute_is_required_template(self):
+ MailMessage = self.env["mail.message"]
+ for wizard in self:
+ if (
+ not wizard.res_model
+ or not wizard.gateway_id
+ or not wizard.number_field_name
+ ):
+ wizard.is_required_template = False
+ continue
+ record = self.env[wizard.res_model].browse(wizard.res_id)
+ is_required_template = True
+ channel = record._whatsapp_get_channel(
+ wizard.number_field_name, wizard.gateway_id
+ )
+ if channel:
+ last_message = MailMessage.search(
+ [
+ ("gateway_type", "=", "whatsapp"),
+ ("model", "=", channel._name),
+ ("res_id", "=", channel.id),
+ ],
+ order="date desc",
+ limit=1,
+ )
+ if last_message:
+ delta = (datetime.now() - last_message.date).total_seconds() / 3600
+ if delta < 24.0:
+ is_required_template = False
+ wizard.is_required_template = is_required_template
+
+ @api.onchange("gateway_id")
+ def onchange_gateway_id(self):
+ self.template_id = False
+
+ @api.onchange("template_id")
+ def onchange_template_id(self):
+ if self.template_id:
+ self.body = self.template_id.render_body_message()
+
+ @api.model
+ def default_get(self, fields):
+ result = super().default_get(fields)
+ gateways = self.env["mail.gateway"].search([("gateway_type", "=", "whatsapp")])
+ result["find_gateway"] = len(gateways) != 1
+ if not result["find_gateway"]:
+ result["gateway_id"] = gateways.id
+ return result
+
+ def _action_send_whatsapp(self):
+ record = self.env[self.res_model].browse(self.res_id)
+ if not record:
+ return
+ channel = record._whatsapp_get_channel(self.number_field_name, self.gateway_id)
+ channel.with_context(whatsapp_template_id=self.template_id.id).message_post(
+ body=self.body, subtype_xmlid="mail.mt_comment", message_type="comment"
+ )
+
+ def action_view_whatsapp(self):
+ self.ensure_one()
+ record = self.env[self.res_model].browse(self.res_id)
+ if not record:
+ return
+ channel = record._whatsapp_get_channel(self.number_field_name, self.gateway_id)
+ if channel:
+ return {
+ "type": "ir.actions.client",
+ "tag": "mail.action_discuss",
+ "params": {"active_id": f"{channel._name}_{channel.id}"},
+ }
+ return False
+
+ def action_send_whatsapp(self):
+ self.ensure_one()
+ if not self.body:
+ raise UserError(self.env._("Body is required"))
+ self._action_send_whatsapp()
+ return False
diff --git a/mail_gateway_whatsapp/wizards/whatsapp_composer.xml b/mail_gateway_whatsapp/wizards/whatsapp_composer.xml
new file mode 100644
index 000000000..03965f23c
--- /dev/null
+++ b/mail_gateway_whatsapp/wizards/whatsapp_composer.xml
@@ -0,0 +1,48 @@
+
+
+
+
+ whatsapp.composer.form (in mail_gateway_whatsapp)
+ whatsapp.composer
+
+
+
+
+
+
+ Send Whatsapp Message
+ whatsapp.composer
+ form
+ {}
+ new
+
+
diff --git a/requirements.txt b/requirements.txt
index 3e01d6c06..154935992 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,4 @@
cairosvg
lottie
python-telegram-bot
+requests_toolbelt