odoo-addon-mail-gateway-whatsapp 17.0.1.0.0.2__py3-none-any.whl → 17.0.1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,19 @@
1
1
  # Copyright 2024 Tecnativa - Carlos López
2
2
  # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3
3
  import re
4
+ from urllib.parse import urlparse
4
5
 
5
6
  import requests
6
7
  from werkzeug.urls import url_join
7
8
 
8
- from odoo import api, fields, models
9
- from odoo.exceptions import UserError
9
+ from odoo import Command, _, api, fields, models
10
+ from odoo.exceptions import UserError, ValidationError
10
11
  from odoo.tools import ustr
11
12
 
12
13
  from odoo.addons.http_routing.models.ir_http import slugify
14
+ from odoo.addons.phone_validation.tools import phone_validation
13
15
 
14
- from ..tools.const import supported_languages
16
+ from ..tools.const import REG_VARIABLE, supported_languages
15
17
  from .mail_gateway import BASE_URL
16
18
 
17
19
 
@@ -63,6 +65,26 @@ class MailWhatsAppTemplate(models.Model):
63
65
  company_id = fields.Many2one(
64
66
  "res.company", related="gateway_id.company_id", store=True
65
67
  )
68
+ model_id = fields.Many2one(
69
+ string="Applies to",
70
+ comodel_name="ir.model",
71
+ default=lambda self: self.env["ir.model"]._get_id("res.partner"),
72
+ required=True,
73
+ ondelete="cascade",
74
+ )
75
+ model = fields.Char(string="Related model", related="model_id.model", store=True)
76
+ variable_ids = fields.One2many(
77
+ "mail.whatsapp.template.variable",
78
+ "template_id",
79
+ string="Variables",
80
+ store=True,
81
+ compute="_compute_variable_ids",
82
+ precompute=True,
83
+ readonly=False,
84
+ )
85
+ button_ids = fields.One2many(
86
+ "mail.whatsapp.template.button", "template_id", string="Buttons"
87
+ )
66
88
 
67
89
  _sql_constraints = [
68
90
  (
@@ -72,6 +94,67 @@ class MailWhatsAppTemplate(models.Model):
72
94
  )
73
95
  ]
74
96
 
97
+ @api.constrains("button_ids")
98
+ def _check_buttons(self):
99
+ for template in self:
100
+ if len(template.button_ids) > 10:
101
+ raise ValidationError(_("A maximum of 10 buttons is allowed."))
102
+ url_buttons = template.button_ids.filtered(
103
+ lambda button: button.button_type == "url"
104
+ )
105
+ phone_number_buttons = template.button_ids.filtered(
106
+ lambda button: button.button_type == "phone_number"
107
+ )
108
+ if len(url_buttons) > 2:
109
+ raise ValidationError(_("A maximum of 2 URL buttons is allowed."))
110
+ if len(phone_number_buttons) > 1:
111
+ raise ValidationError(
112
+ _("A maximum of 1 Call Number button is allowed.")
113
+ )
114
+
115
+ @api.constrains("variable_ids")
116
+ def _check_variables(self):
117
+ for template in self:
118
+ body_variables = template.variable_ids.filtered(
119
+ lambda var: var.line_type == "body"
120
+ )
121
+ header_variables = template.variable_ids.filtered(
122
+ lambda var: var.line_type == "header"
123
+ )
124
+ if len(header_variables) > 1:
125
+ raise ValidationError(
126
+ _("There should be exactly 1 variable in the header.")
127
+ )
128
+ if header_variables and header_variables._extract_variable_index() != 1:
129
+ raise ValidationError(
130
+ _("Variable in the header should be used as {{1}}")
131
+ )
132
+ variable_indices = sorted(
133
+ var._extract_variable_index() for var in body_variables
134
+ )
135
+ if len(variable_indices) > 0 and (
136
+ variable_indices[0] != 1 or variable_indices[-1] != len(body_variables)
137
+ ):
138
+ missing = (
139
+ next(
140
+ (
141
+ index
142
+ for index in range(1, len(body_variables))
143
+ if variable_indices[index - 1] + 1
144
+ != variable_indices[index]
145
+ ),
146
+ 0,
147
+ )
148
+ + 1
149
+ )
150
+ raise ValidationError(
151
+ _(
152
+ "Body variables should start at 1 and not skip any number, "
153
+ "missing %d",
154
+ missing,
155
+ )
156
+ )
157
+
75
158
  @api.depends("name", "state", "template_uid")
76
159
  def _compute_template_name(self):
77
160
  for template in self:
@@ -82,6 +165,54 @@ class MailWhatsAppTemplate(models.Model):
82
165
  r"\W+", "_", slugify(template.name or "")
83
166
  )
84
167
 
168
+ @api.depends("header", "body")
169
+ def _compute_variable_ids(self):
170
+ for template in self:
171
+ to_remove = self.env["mail.whatsapp.template.variable"]
172
+ to_keep = self.env["mail.whatsapp.template.variable"]
173
+ new_values = []
174
+ header_variables = list(re.findall(REG_VARIABLE, template.header or ""))
175
+ body_variables = set(re.findall(REG_VARIABLE, template.body or ""))
176
+ # header
177
+ current_header_variable = template.variable_ids.filtered(
178
+ lambda line: line.line_type == "header"
179
+ )
180
+ if header_variables and not current_header_variable:
181
+ new_values.append(
182
+ {
183
+ "name": header_variables[0],
184
+ "line_type": "header",
185
+ "template_id": template.id,
186
+ }
187
+ )
188
+ elif not header_variables and current_header_variable:
189
+ to_remove += current_header_variable
190
+ elif current_header_variable:
191
+ to_keep += current_header_variable
192
+ # body
193
+ current_body_variables = template.variable_ids.filtered(
194
+ lambda line: line.line_type == "body"
195
+ )
196
+ new_body_variable_names = [
197
+ var_name
198
+ for var_name in body_variables
199
+ if var_name not in current_body_variables.mapped("name")
200
+ ]
201
+ deleted_variables = current_body_variables.filtered(
202
+ lambda var, body_variables=body_variables: var.name
203
+ not in body_variables
204
+ )
205
+
206
+ new_values += [
207
+ {"name": var_name, "line_type": "body", "template_id": template.id}
208
+ for var_name in set(new_body_variable_names)
209
+ ]
210
+ to_remove += deleted_variables
211
+ to_keep += current_body_variables - deleted_variables
212
+ template.variable_ids = [(3, to_remove.id) for to_remove in to_remove] + [
213
+ Command.create(vals) for vals in new_values
214
+ ]
215
+
85
216
  def button_back2draft(self):
86
217
  self.write({"state": "draft"})
87
218
 
@@ -125,23 +256,39 @@ class MailWhatsAppTemplate(models.Model):
125
256
  }
126
257
 
127
258
  def _prepare_components_to_export(self):
128
- components = [{"type": "BODY", "text": self.body}]
259
+ body_component = {"type": "BODY", "text": self.body}
260
+ body_params = self.variable_ids.filtered(lambda line: line.line_type == "body")
261
+ if body_params:
262
+ body_component["example"] = {
263
+ "body_text": [body_params.mapped("sample_value")]
264
+ }
265
+ components = [body_component]
129
266
  if self.header:
130
- components.append(
131
- {
132
- "type": "HEADER",
133
- "format": "text",
134
- "text": self.header,
135
- }
267
+ header_component = {"type": "HEADER", "format": "TEXT", "text": self.header}
268
+ header_params = self.variable_ids.filtered(
269
+ lambda line: line.line_type == "header"
136
270
  )
137
- if self.footer:
138
- components.append(
139
- {
140
- "type": "FOOTER",
141
- "text": self.footer,
271
+ if header_params:
272
+ header_component["example"] = {
273
+ "header_text": header_params.mapped("sample_value")
142
274
  }
143
- )
144
- # TODO: add more components(buttons, location, etc)
275
+ components.append(header_component)
276
+ if self.footer:
277
+ components.append({"type": "FOOTER", "text": self.footer})
278
+ buttons = []
279
+ for button in self.button_ids:
280
+ button_data = {"type": button.button_type.upper(), "text": button.name}
281
+ if button.button_type == "url":
282
+ button_data["url"] = button.website_url
283
+ if button.url_type == "dynamic":
284
+ button_data["url"] += "{{1}}"
285
+ button_data["example"] = button.variable_ids[0].sample_value
286
+ elif button.button_type == "phone_number":
287
+ button_data["phone_number"] = button.call_number
288
+ buttons.append(button_data)
289
+ if buttons:
290
+ components.append({"type": "BUTTONS", "buttons": buttons})
291
+ # TODO: add more components(location, etc)
145
292
  return components
146
293
 
147
294
  def button_sync_template(self):
@@ -187,7 +334,382 @@ class MailWhatsAppTemplate(models.Model):
187
334
  vals["body"] = component["text"]
188
335
  elif component["type"] == "FOOTER":
189
336
  vals["footer"] = component["text"]
337
+ elif component["type"] == "BUTTONS":
338
+ for index, button in enumerate(component["buttons"]):
339
+ if button["type"] in ("URL", "PHONE_NUMBER", "QUICK_REPLY"):
340
+ button_vals = {
341
+ "sequence": index,
342
+ "name": button["text"],
343
+ "button_type": button["type"].lower(),
344
+ "call_number": button.get("phone_number"),
345
+ "website_url": button.get("url"),
346
+ }
347
+ vals.setdefault("button_ids", [])
348
+ button = self.button_ids.filtered(
349
+ lambda btn, button=button: btn.name == button["text"]
350
+ )
351
+ if button:
352
+ vals["button_ids"].append(
353
+ Command.update(button.id, button_vals)
354
+ )
355
+ else:
356
+ vals["button_ids"].append(Command.create(button_vals))
190
357
  else:
191
358
  is_supported = False
192
359
  vals["is_supported"] = is_supported
193
360
  return vals
361
+
362
+ def _prepare_header_component(self, variable_ids_value):
363
+ header = []
364
+ if self.header and variable_ids_value.get("header-{{1}}"):
365
+ value = variable_ids_value.get("header-{{1}}") or (self.header or {}) or ""
366
+ header = {"type": "header", "parameters": [{"type": "text", "text": value}]}
367
+ return header
368
+
369
+ def _prepare_body_components(self, variable_ids_value):
370
+ if not self.variable_ids:
371
+ return None
372
+ parameters = []
373
+ for body_val in self.variable_ids.filtered(
374
+ lambda line: line.line_type == "body"
375
+ ):
376
+ parameters.append(
377
+ {
378
+ "type": "text",
379
+ "text": variable_ids_value.get(
380
+ f"{body_val.line_type}-{body_val.name}"
381
+ )
382
+ or " ",
383
+ }
384
+ )
385
+ return {"type": "body", "parameters": parameters}
386
+
387
+ def _prepare_button_components(self, variable_ids_value):
388
+ components = []
389
+ dynamic_buttons = self.button_ids.filtered(
390
+ lambda line: line.url_type == "dynamic"
391
+ )
392
+ index = {button: i for i, button in enumerate(self.button_ids)}
393
+ for button in dynamic_buttons:
394
+ dynamic_url = button.website_url
395
+ value = variable_ids_value.get(f"button-{button.name}") or " "
396
+ value = value.replace(dynamic_url, "").lstrip("/")
397
+ components.append(
398
+ {
399
+ "type": "button",
400
+ "sub_type": "url",
401
+ "index": index.get(button),
402
+ "parameters": [{"type": "text", "text": value}],
403
+ }
404
+ )
405
+ return components
406
+
407
+ def prepare_value_to_send(self):
408
+ self.ensure_one()
409
+ model_name = self.model_id.model
410
+ rec_id = self.env.context.get("default_res_id")
411
+ if rec_id is None:
412
+ rec_ids = self.env.context.get("res_id")
413
+ if rec_ids:
414
+ rec_id = rec_ids
415
+ else:
416
+ rec_id = None
417
+ if model_name and rec_id:
418
+ record = self.env[model_name].browse(int(rec_id))
419
+ components = []
420
+ variable_ids_value = self.variable_ids._get_variables_value(record)
421
+ # generate components
422
+ header = self._prepare_header_component(variable_ids_value=variable_ids_value)
423
+ body = self._prepare_body_components(variable_ids_value=variable_ids_value)
424
+ buttons = self._prepare_button_components(variable_ids_value=variable_ids_value)
425
+ if header:
426
+ components.append(header)
427
+ if body:
428
+ components.append(body)
429
+ components.extend(buttons)
430
+ return components
431
+
432
+ def render_body_message(self):
433
+ self.ensure_one()
434
+ model_name = self.model_id.model
435
+ rec_id = self.env.context.get("default_res_id")
436
+ if rec_id is None:
437
+ rec_ids = self.env.context.get("default_res_ids")
438
+ if isinstance(rec_ids, list) and rec_ids:
439
+ rec_id = rec_ids[0]
440
+ else:
441
+ rec_id = None
442
+ if model_name and rec_id:
443
+ record = self.env[model_name].browse(int(rec_id))
444
+ header = ""
445
+ if self.header:
446
+ header = self.header
447
+ header_vars = self.variable_ids.filtered(lambda v: v.line_type == "header")
448
+ for i, var in enumerate(header_vars, start=1):
449
+ placeholder = f"{{{{{i}}}}}"
450
+ value = var._get_variables_value(record).get(
451
+ f"header-{placeholder}", ""
452
+ )
453
+ header = header.replace(placeholder, str(value))
454
+ body = self.body or ""
455
+ body_vars = self.variable_ids.filtered(lambda v: v.line_type == "body")
456
+ for i, var in enumerate(body_vars, start=1):
457
+ placeholder = f"{{{{{i}}}}}"
458
+ value = var._get_variables_value(record).get(f"body-{placeholder}", "")
459
+ body = body.replace(placeholder, str(value))
460
+ message = f"*{header}*\n\n{body}" if header else body
461
+ return message
462
+
463
+
464
+ class MailWhatsAppTemplateVariable(models.Model):
465
+ _name = "mail.whatsapp.template.variable"
466
+ _description = "WhatsApp Template Variable"
467
+ _order = "line_type desc, name, id"
468
+
469
+ name = fields.Char(string="Placeholder", required=True)
470
+ template_id = fields.Many2one(
471
+ comodel_name="mail.whatsapp.template", required=True, ondelete="cascade"
472
+ )
473
+ model = fields.Char(string="Model Name", related="template_id.model")
474
+ line_type = fields.Selection(
475
+ [("header", "Header"), ("body", "Body"), ("button", "Button")], required=True
476
+ )
477
+ field_name = fields.Char(string="Field")
478
+ sample_value = fields.Char(default="Sample Value", required=True)
479
+ button_id = fields.Many2one("mail.whatsapp.template.button", ondelete="cascade")
480
+
481
+ _sql_constraints = [
482
+ (
483
+ "name_type_template_unique",
484
+ "UNIQUE(name, line_type, template_id,button_id)",
485
+ "Variable names must be unique by template",
486
+ ),
487
+ ]
488
+
489
+ @api.constrains("field_name")
490
+ def _check_field_name(self):
491
+ failing = self.browse()
492
+ missing = self.filtered(lambda variable: not variable.field_name)
493
+ if missing:
494
+ raise ValidationError(
495
+ _(
496
+ "Field template variables %(variables)s "
497
+ "must be associated with a field.",
498
+ variables=", ".join(missing.mapped("name")),
499
+ )
500
+ )
501
+ for variable in self:
502
+ model = self.env[variable.model]
503
+ if not model.check_access_rights("read", raise_exception=False):
504
+ model_description = (
505
+ self.env["ir.model"]._get(variable.model).display_name
506
+ )
507
+ raise ValidationError(
508
+ _("You can not select field of %(model)s.", model=model_description)
509
+ )
510
+ try:
511
+ variable._extract_value_from_field_path(model)
512
+ except UserError:
513
+ failing += variable
514
+ if failing:
515
+ model_description = (
516
+ self.env["ir.model"]._get(failing.mapped("model")[0]).display_name
517
+ )
518
+ raise ValidationError(
519
+ _(
520
+ "Variables %(field_names)s do not seem to be valid field path "
521
+ "for model %(model_name)s.",
522
+ field_names=", ".join(failing.mapped("field_name")),
523
+ model_name=model_description,
524
+ )
525
+ )
526
+
527
+ @api.constrains("name")
528
+ def _check_name(self):
529
+ for variable in self:
530
+ if not variable._extract_variable_index():
531
+ raise ValidationError(
532
+ _(
533
+ "Template variable should be in format {{number}}. "
534
+ "Cannot parse '%(placeholder)s'",
535
+ placeholder=variable.name,
536
+ )
537
+ )
538
+
539
+ @api.depends("line_type", "name")
540
+ def _compute_display_name(self):
541
+ type_names = dict(self._fields["line_type"]._description_selection(self.env))
542
+ for variable in self:
543
+ type_name = type_names[variable.line_type or "body"]
544
+ variable.display_name = (
545
+ type_name
546
+ if variable.line_type == "header"
547
+ else f"{type_name} - {variable.name}"
548
+ )
549
+
550
+ @api.onchange("model")
551
+ def _onchange_model_id(self):
552
+ self.field_name = False
553
+
554
+ def _get_variables_value(self, record):
555
+ value_by_name = {}
556
+ for variable in self:
557
+ value = variable._extract_value_from_field_path(record)
558
+ value_str = value and str(value) or ""
559
+ value_key = f"{variable.line_type}-{variable.name}"
560
+ value_by_name[value_key] = value_str
561
+ return value_by_name
562
+
563
+ def _extract_variable_index(self):
564
+ """Extract variable index, located between '{{}}' markers."""
565
+ self.ensure_one()
566
+ try:
567
+ return int(self.name.replace("{{", "").replace("}}", ""))
568
+ except ValueError:
569
+ return None
570
+
571
+ def _extract_value_from_field_path(self, record):
572
+ field_path = self.field_name
573
+ if not field_path:
574
+ return ""
575
+ try:
576
+ field_value = record.mapped(field_path)
577
+ except Exception as err:
578
+ raise UserError(
579
+ _(
580
+ "We were not able to fetch value of field: %(field)s",
581
+ field=field_path,
582
+ )
583
+ ) from err
584
+ if isinstance(field_value, models.Model):
585
+ return " ".join((value.display_name or "") for value in field_value)
586
+ # find last field / last model when having chained fields
587
+ # e.g. 'partner_id.country_id.state' -> ['partner_id.country_id', 'state']
588
+ field_path_models = field_path.rsplit(".", 1)
589
+ if len(field_path_models) > 1:
590
+ last_model_path, last_fname = field_path_models
591
+ last_model = record.mapped(last_model_path)
592
+ else:
593
+ last_model, last_fname = record, field_path
594
+ last_field = last_model._fields[last_fname]
595
+ # return value instead of the key
596
+ if last_field.type == "selection":
597
+ return " ".join(
598
+ last_field.convert_to_export(value, last_model) for value in field_value
599
+ )
600
+ return " ".join(
601
+ str(value if value is not False and value is not None else "")
602
+ for value in field_value
603
+ )
604
+
605
+
606
+ class MailWhatsAppTemplateButton(models.Model):
607
+ _name = "mail.whatsapp.template.button"
608
+ _description = "WhatsApp Template Button"
609
+ _order = "sequence,id"
610
+
611
+ sequence = fields.Integer()
612
+ name = fields.Char(string="Button Text", size=25)
613
+ template_id = fields.Many2one(
614
+ comodel_name="mail.whatsapp.template", required=True, ondelete="cascade"
615
+ )
616
+ button_type = fields.Selection(
617
+ [
618
+ ("quick_reply", "Quick Reply"),
619
+ ("url", "Visit Website"),
620
+ ("phone_number", "Call Number"),
621
+ ],
622
+ string="Type",
623
+ required=True,
624
+ default="quick_reply",
625
+ )
626
+ url_type = fields.Selection(
627
+ [("static", "Static"), ("dynamic", "Dynamic")],
628
+ default="static",
629
+ )
630
+ website_url = fields.Char()
631
+ call_number = fields.Char()
632
+ variable_ids = fields.One2many(
633
+ "mail.whatsapp.template.variable",
634
+ "button_id",
635
+ compute="_compute_variable_ids",
636
+ precompute=True,
637
+ store=True,
638
+ copy=True,
639
+ )
640
+
641
+ _sql_constraints = [
642
+ (
643
+ "unique_name_per_template",
644
+ "UNIQUE(name, template_id)",
645
+ "Button name must be unique by template",
646
+ )
647
+ ]
648
+
649
+ @api.constrains("button_type", "url_type", "website_url")
650
+ def _validate_website_url(self):
651
+ for button in self.filtered(lambda button: button.button_type == "url"):
652
+ parsed_url = urlparse(button.website_url)
653
+ if not (parsed_url.scheme in {"http", "https"} and parsed_url.netloc):
654
+ raise ValidationError(
655
+ _(
656
+ "Please enter a valid URL in the format 'https://www.example.com'."
657
+ )
658
+ )
659
+
660
+ @api.constrains("call_number")
661
+ def _validate_call_number(self):
662
+ for button in self:
663
+ if button.button_type == "phone_number":
664
+ phone_validation.phone_format(button.call_number, False, False)
665
+
666
+ @api.depends("button_type", "url_type", "website_url", "name")
667
+ def _compute_variable_ids(self):
668
+ dynamic_urls = self.filtered(
669
+ lambda button: button.button_type == "url" and button.url_type == "dynamic"
670
+ )
671
+ to_clear = self - dynamic_urls
672
+ for button in dynamic_urls:
673
+ if button.variable_ids:
674
+ button.variable_ids = [
675
+ (
676
+ 1,
677
+ button.variable_ids[0].id,
678
+ {
679
+ "sample_value": button.website_url,
680
+ "line_type": "button",
681
+ "name": button.name,
682
+ "button_id": button.id,
683
+ "template_id": button.template_id.id,
684
+ },
685
+ ),
686
+ ]
687
+ else:
688
+ button.variable_ids = [
689
+ (
690
+ 0,
691
+ 0,
692
+ {
693
+ "sample_value": button.website_url,
694
+ "line_type": "button",
695
+ "name": button.name,
696
+ "button_id": button.id,
697
+ "template_id": button.template_id.id,
698
+ },
699
+ ),
700
+ ]
701
+ if to_clear:
702
+ to_clear.variable_ids = [(5, 0)]
703
+
704
+ def check_variable_ids(self):
705
+ for button in self:
706
+ if len(button.variable_ids) > 1:
707
+ raise ValidationError(_("Buttons may only contain one placeholder."))
708
+ if button.variable_ids and button.url_type != "dynamic":
709
+ raise ValidationError(_("Only dynamic urls may have a placeholder."))
710
+ elif button.url_type == "dynamic" and not button.variable_ids:
711
+ raise ValidationError(_("All dynamic urls must have a placeholder."))
712
+ if button.variable_ids.name != "{{1}}":
713
+ raise ValidationError(
714
+ _("The placeholder for a button can only be {{1}}.")
715
+ )
@@ -2,3 +2,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2
2
  access_whatsapp_composer,access.whatsapp.composer,model_whatsapp_composer,base.group_user,1,1,1,0
3
3
  access_mail_whatsapp_template_group_system,mail_whatsapp_template_group_system,model_mail_whatsapp_template,base.group_system,1,1,1,1
4
4
  access_mail_whatsapp_template_group_user,mail_whatsapp_template_group_user,model_mail_whatsapp_template,base.group_user,1,0,0,0
5
+ 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
6
+ 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
7
+ 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
8
+ 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
@@ -372,7 +372,7 @@ ul.auto-toc {
372
372
  !! This file is generated by oca-gen-addon-readme !!
373
373
  !! changes will be overwritten. !!
374
374
  !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
375
- !! source digest: sha256:abc9cf8f5146c06cb1a2171d01ee1797a2f5a092bc0dd29c5e39106dadc14efd
375
+ !! source digest: sha256:2330f1752351311f792b205805bb8a22e000deb1c18c25801bb5675d1aef5f3f
376
376
  !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
377
377
  <p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/social/tree/17.0/mail_gateway_whatsapp"><img alt="OCA/social" src="https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/social-17-0/social-17-0-mail_gateway_whatsapp"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/social&amp;target_branch=17.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
378
378
  <p>This module allows to respond whatsapp chats.</p>