mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-22 22:41:59 +02:00
997 lines
43 KiB
Python
997 lines
43 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, _, tools
|
|
from odoo.addons.mail.tools.discuss import Store
|
|
from odoo.tools import email_normalize, email_split, html2plaintext, plaintext2html
|
|
from odoo.tools.mimetypes import get_extension
|
|
|
|
import json
|
|
from markupsafe import Markup
|
|
from pytz import timezone
|
|
|
|
|
|
def is_livechat_channel(channel):
|
|
"""Predicate to filter channels for which the channel type is 'livechat'.
|
|
|
|
:returns: Whether the channel is a live chat channel.
|
|
:rtype: bool
|
|
"""
|
|
return channel.channel_type == "livechat"
|
|
|
|
|
|
class DiscussChannel(models.Model):
|
|
""" Chat Session
|
|
Reprensenting a conversation between users.
|
|
It extends the base method for anonymous usage.
|
|
"""
|
|
|
|
_name = 'discuss.channel'
|
|
_inherit = ['rating.mixin', 'discuss.channel']
|
|
|
|
channel_type = fields.Selection(selection_add=[('livechat', 'Livechat Conversation')], ondelete={'livechat': 'cascade'})
|
|
duration = fields.Float('Duration', compute='_compute_duration', help='Duration of the session in hours')
|
|
livechat_lang_id = fields.Many2one("res.lang", string="Language", help="Lang of the visitor of the channel.")
|
|
livechat_end_dt = fields.Datetime(
|
|
"Session end date",
|
|
help="Session is closed when either the visitor or the last agent leaves the conversation.",
|
|
)
|
|
livechat_channel_id = fields.Many2one('im_livechat.channel', 'Channel', index='btree_not_null')
|
|
livechat_operator_id = fields.Many2one('res.partner', string='Operator', index='btree_not_null')
|
|
livechat_channel_member_history_ids = fields.One2many("im_livechat.channel.member.history", "channel_id")
|
|
livechat_expertise_ids = fields.Many2many(
|
|
"im_livechat.expertise",
|
|
"discuss_channel_im_livechat_expertise_rel",
|
|
"discuss_channel_id",
|
|
"im_livechat_expertise_id",
|
|
store=True,
|
|
)
|
|
livechat_agent_history_ids = fields.One2many(
|
|
"im_livechat.channel.member.history",
|
|
string="Agents (History)",
|
|
compute="_compute_livechat_agent_history_ids",
|
|
search="_search_livechat_agent_history_ids",
|
|
)
|
|
livechat_bot_history_ids = fields.One2many(
|
|
"im_livechat.channel.member.history",
|
|
string="Bots (History)",
|
|
compute="_compute_livechat_bot_history_ids",
|
|
search="_search_livechat_bot_history_ids",
|
|
)
|
|
livechat_customer_history_ids = fields.One2many(
|
|
"im_livechat.channel.member.history",
|
|
string="Customers (History)",
|
|
compute="_compute_livechat_customer_history_ids",
|
|
search="_search_livechat_customer_history_ids",
|
|
)
|
|
livechat_agent_partner_ids = fields.Many2many(
|
|
"res.partner",
|
|
"im_livechat_channel_member_history_discuss_channel_agent_rel",
|
|
string="Agents",
|
|
compute="_compute_livechat_agent_partner_ids",
|
|
store=True,
|
|
)
|
|
livechat_bot_partner_ids = fields.Many2many(
|
|
"res.partner",
|
|
"im_livechat_channel_member_history_discuss_channel_bot_rel",
|
|
string="Bots",
|
|
compute="_compute_livechat_bot_partner_ids",
|
|
context={"active_test": False},
|
|
store=True,
|
|
)
|
|
livechat_customer_partner_ids = fields.Many2many(
|
|
"res.partner",
|
|
"im_livechat_channel_member_history_discuss_channel_customer_rel",
|
|
string="Customers (Partners)",
|
|
compute="_compute_livechat_customer_partner_ids",
|
|
store=True,
|
|
)
|
|
livechat_customer_guest_ids = fields.Many2many(
|
|
"mail.guest",
|
|
string="Customers (Guests)",
|
|
compute="_compute_livechat_customer_guest_ids",
|
|
)
|
|
livechat_agent_requesting_help_history = fields.Many2one(
|
|
"im_livechat.channel.member.history",
|
|
string="Help Requested (Agent)",
|
|
compute="_compute_livechat_agent_requesting_help_history",
|
|
store=True,
|
|
)
|
|
livechat_agent_providing_help_history = fields.Many2one(
|
|
"im_livechat.channel.member.history",
|
|
string="Help Provided (Agent)",
|
|
compute="_compute_livechat_agent_providing_help_history",
|
|
store=True,
|
|
)
|
|
livechat_note = fields.Html(
|
|
"Live Chat Note",
|
|
sanitize_style=True,
|
|
groups="base.group_user",
|
|
help="Note about the session, visible to all internal users having access to the session.",
|
|
)
|
|
livechat_status = fields.Selection(
|
|
selection=[
|
|
("in_progress", "In progress"),
|
|
("waiting", "Waiting for customer"),
|
|
("need_help", "Looking for help"),
|
|
],
|
|
compute="_compute_livechat_status",
|
|
groups="base.group_user",
|
|
readonly=False,
|
|
store=True,
|
|
)
|
|
livechat_outcome = fields.Selection(
|
|
[
|
|
("no_answer", "Never Answered"),
|
|
("no_agent", "No one Available"),
|
|
("no_failure", "Success"),
|
|
("escalated", "Escalated"),
|
|
],
|
|
compute="_compute_livechat_outcome",
|
|
store=True,
|
|
)
|
|
livechat_conversation_tag_ids = fields.Many2many(
|
|
"im_livechat.conversation.tag",
|
|
"livechat_conversation_tag_rel",
|
|
groups="im_livechat.im_livechat_group_user",
|
|
string="Live Chat Conversation Tags",
|
|
help="Tags to qualify the conversation.",
|
|
)
|
|
livechat_start_hour = fields.Float(
|
|
"Session Start Hour", compute="_compute_livechat_start_hour", store=True
|
|
)
|
|
livechat_week_day = fields.Selection(
|
|
[
|
|
("0", "Monday"),
|
|
("1", "Tuesday"),
|
|
("2", "Wednesday"),
|
|
("3", "Thursday"),
|
|
("4", "Friday"),
|
|
("5", "Saturday"),
|
|
("6", "Sunday"),
|
|
],
|
|
string="Day of the Week",
|
|
compute="_compute_livechat_week_day",
|
|
store=True,
|
|
)
|
|
livechat_matches_self_lang = fields.Boolean(
|
|
compute="_compute_livechat_matches_self_lang", search="_search_livechat_matches_self_lang"
|
|
)
|
|
livechat_matches_self_expertise = fields.Boolean(
|
|
compute="_compute_livechat_matches_self_expertise",
|
|
search="_search_livechat_matches_self_expertise",
|
|
)
|
|
|
|
chatbot_current_step_id = fields.Many2one('chatbot.script.step', string='Chatbot Current Step')
|
|
chatbot_message_ids = fields.One2many('chatbot.message', 'discuss_channel_id', string='Chatbot Messages')
|
|
country_id = fields.Many2one('res.country', string="Country", help="Country of the visitor of the channel")
|
|
livechat_failure = fields.Selection(
|
|
selection=[
|
|
("no_answer", "Never Answered"),
|
|
("no_agent", "No one Available"),
|
|
("no_failure", "No Failure"),
|
|
],
|
|
string="Live Chat Session Failure",
|
|
)
|
|
livechat_is_escalated = fields.Boolean("Is session escalated", compute="_compute_livechat_is_escalated", store=True)
|
|
rating_last_text = fields.Selection(store=True)
|
|
|
|
_livechat_operator_id = models.Constraint(
|
|
"CHECK((channel_type = 'livechat' and livechat_operator_id is not null) or (channel_type != 'livechat'))",
|
|
'Livechat Operator ID is required for a channel of type livechat.',
|
|
)
|
|
_livechat_end_dt_status_constraint = models.Constraint(
|
|
"CHECK(livechat_end_dt IS NULL or livechat_status IS NULL)",
|
|
"Closed Live Chat session should not have a status.",
|
|
)
|
|
_livechat_end_dt_idx = models.Index("(livechat_end_dt) WHERE livechat_end_dt IS NULL")
|
|
_livechat_failure_idx = models.Index(
|
|
"(livechat_failure) WHERE livechat_failure IN ('no_answer', 'no_agent')"
|
|
)
|
|
_livechat_is_escalated_idx = models.Index(
|
|
"(livechat_is_escalated) WHERE livechat_is_escalated IS TRUE"
|
|
)
|
|
_livechat_channel_type_create_date_idx = models.Index(
|
|
"(channel_type, create_date) WHERE channel_type = 'livechat'"
|
|
)
|
|
|
|
def write(self, vals):
|
|
if "livechat_status" not in vals and "livechat_expertise_ids" not in vals:
|
|
return super().write(vals)
|
|
need_help_before = self.filtered(lambda c: c.livechat_status == "need_help")
|
|
result = super().write(vals)
|
|
need_help_after = self.filtered(lambda c: c.livechat_status == "need_help")
|
|
group_livechat_user = self.env.ref("im_livechat.im_livechat_group_user")
|
|
store = Store(bus_channel=group_livechat_user, bus_subchannel="LOOKING_FOR_HELP")
|
|
added_need_help = need_help_after - need_help_before
|
|
removed_need_help = need_help_before - need_help_after
|
|
store.add(added_need_help)
|
|
store.add(removed_need_help, ["livechat_status"])
|
|
if "livechat_expertise_ids" in vals:
|
|
store.add(self, Store.Many("livechat_expertise_ids"))
|
|
if added_need_help or removed_need_help:
|
|
group_livechat_user._bus_send(
|
|
"im_livechat.looking_for_help/update",
|
|
{
|
|
"added_channel_ids": added_need_help.ids,
|
|
"removed_channel_ids": removed_need_help.ids,
|
|
},
|
|
subchannel="LOOKING_FOR_HELP",
|
|
)
|
|
store.bus_send()
|
|
return result
|
|
|
|
@api.depends("livechat_end_dt")
|
|
def _compute_duration(self):
|
|
for record in self:
|
|
end = record.livechat_end_dt or fields.Datetime.now()
|
|
start = record.create_date or fields.Datetime.now()
|
|
record.duration = (end - start).total_seconds() / 3600
|
|
|
|
@api.depends("livechat_end_dt")
|
|
def _compute_livechat_status(self):
|
|
for channel in self.filtered(lambda c: c.livechat_end_dt):
|
|
channel.livechat_status = False
|
|
|
|
@api.depends("livechat_agent_history_ids")
|
|
def _compute_livechat_is_escalated(self):
|
|
for channel in self:
|
|
channel.livechat_is_escalated = len(channel.livechat_agent_history_ids) > 1
|
|
|
|
@api.depends("livechat_channel_member_history_ids.livechat_member_type")
|
|
def _compute_livechat_agent_history_ids(self):
|
|
for channel in self:
|
|
channel.livechat_agent_history_ids = (
|
|
channel.livechat_channel_member_history_ids.filtered(
|
|
lambda h: h.livechat_member_type == "agent",
|
|
)
|
|
)
|
|
|
|
@api.depends("livechat_channel_member_history_ids.livechat_member_type")
|
|
def _compute_livechat_bot_history_ids(self):
|
|
for channel in self:
|
|
channel.livechat_bot_history_ids = channel.livechat_channel_member_history_ids.filtered(
|
|
lambda h: h.livechat_member_type == "bot",
|
|
)
|
|
|
|
def _search_livechat_bot_history_ids(self, operator, value):
|
|
if operator != "in":
|
|
return NotImplemented
|
|
bot_history_query = self.env["im_livechat.channel.member.history"]._search(
|
|
[
|
|
("livechat_member_type", "=", "bot"),
|
|
("id", "in", value),
|
|
],
|
|
)
|
|
return [("id", "in", bot_history_query.subselect("channel_id"))]
|
|
|
|
@api.depends("livechat_channel_member_history_ids.livechat_member_type")
|
|
def _compute_livechat_customer_history_ids(self):
|
|
for channel in self:
|
|
channel.livechat_customer_history_ids = (
|
|
channel.livechat_channel_member_history_ids.filtered(
|
|
lambda h: h.livechat_member_type == "visitor",
|
|
)
|
|
)
|
|
|
|
def _search_livechat_customer_history_ids(self, operator, value):
|
|
if operator != "in":
|
|
return NotImplemented
|
|
customer_history_query = self.env["im_livechat.channel.member.history"]._search(
|
|
[
|
|
("livechat_member_type", "=", "visitor"),
|
|
("id", "in", value),
|
|
],
|
|
)
|
|
return [("id", "in", customer_history_query.subselect("channel_id"))]
|
|
|
|
@api.depends("livechat_agent_history_ids.partner_id")
|
|
def _compute_livechat_agent_partner_ids(self):
|
|
for channel in self:
|
|
channel.livechat_agent_partner_ids = (
|
|
channel.livechat_agent_history_ids.partner_id
|
|
)
|
|
|
|
def _search_livechat_agent_history_ids(self, operator, value):
|
|
if operator not in ("any", "in"):
|
|
return NotImplemented
|
|
if operator == "in" and len(value) == 1 and not next(iter(value)):
|
|
return [
|
|
(
|
|
"id",
|
|
"not in",
|
|
self.env["im_livechat.channel.member.history"]
|
|
._search([("livechat_member_type", "=", "agent")])
|
|
.subselect("channel_id"),
|
|
),
|
|
]
|
|
query = (
|
|
self.env["im_livechat.channel.member.history"]._search(value)
|
|
if isinstance(value, fields.Domain)
|
|
else value
|
|
)
|
|
agent_history_query = self.env["im_livechat.channel.member.history"]._search(
|
|
[
|
|
("livechat_member_type", "=", "agent"),
|
|
("id", "in", query),
|
|
],
|
|
)
|
|
return [("id", "in", agent_history_query.subselect("channel_id"))]
|
|
|
|
@api.depends("livechat_bot_history_ids.partner_id")
|
|
def _compute_livechat_bot_partner_ids(self):
|
|
for channel in self:
|
|
channel.livechat_bot_partner_ids = (
|
|
channel.livechat_bot_history_ids.partner_id
|
|
)
|
|
|
|
@api.depends("livechat_customer_history_ids.partner_id")
|
|
def _compute_livechat_customer_partner_ids(self):
|
|
for channel in self:
|
|
channel.livechat_customer_partner_ids = (
|
|
channel.livechat_customer_history_ids.partner_id
|
|
)
|
|
|
|
# @api.depends("livechat_customer_history_ids.guest_id")
|
|
def _compute_livechat_customer_guest_ids(self):
|
|
for channel in self:
|
|
channel.livechat_customer_guest_ids = (
|
|
channel.livechat_customer_history_ids.guest_id
|
|
)
|
|
|
|
@api.depends("livechat_agent_history_ids")
|
|
def _compute_livechat_agent_requesting_help_history(self):
|
|
for channel in self:
|
|
channel.livechat_agent_requesting_help_history = (
|
|
channel.livechat_agent_history_ids.sorted(lambda h: (h.create_date, h.id))[0]
|
|
if channel.livechat_is_escalated
|
|
else None
|
|
)
|
|
|
|
@api.depends("livechat_agent_history_ids")
|
|
def _compute_livechat_agent_providing_help_history(self):
|
|
for channel in self:
|
|
channel.livechat_agent_providing_help_history = (
|
|
channel.livechat_agent_history_ids.sorted(
|
|
lambda h: (h.create_date, h.id), reverse=True
|
|
)[0]
|
|
if channel.livechat_is_escalated
|
|
else None
|
|
)
|
|
|
|
@api.depends("livechat_is_escalated", "livechat_failure")
|
|
def _compute_livechat_outcome(self):
|
|
for channel in self:
|
|
self.livechat_outcome = (
|
|
"escalated" if channel.livechat_is_escalated else channel.livechat_failure
|
|
)
|
|
|
|
@api.depends_context("user")
|
|
def _compute_livechat_matches_self_lang(self):
|
|
for channel in self:
|
|
channel.livechat_matches_self_lang = (
|
|
channel.livechat_lang_id in self.env.user.livechat_lang_ids
|
|
or channel.livechat_lang_id.code == self.env.user.lang
|
|
)
|
|
|
|
def _search_livechat_matches_self_lang(self, operator, value):
|
|
if operator != "in" or value not in ({True}, {False}):
|
|
return NotImplemented
|
|
operator = "in" if value == {True} else "not in"
|
|
lang_codes = self.env.user.livechat_lang_ids.mapped("code")
|
|
lang_codes.append(self.env.user.lang)
|
|
return [("livechat_lang_id.code", operator, lang_codes)]
|
|
|
|
@api.depends_context("user")
|
|
def _compute_livechat_matches_self_expertise(self):
|
|
for channel in self:
|
|
channel.livechat_matches_self_expertise = bool(
|
|
channel.livechat_expertise_ids & self.env.user.livechat_expertise_ids
|
|
)
|
|
|
|
def _search_livechat_matches_self_expertise(self, operator, value):
|
|
if operator != "in" or value not in ({True}, {False}):
|
|
return NotImplemented
|
|
operator = "in" if value == {True} else "not in"
|
|
return [("livechat_expertise_ids", operator, self.env.user.livechat_expertise_ids.ids)]
|
|
|
|
@api.depends("create_date")
|
|
def _compute_livechat_start_hour(self):
|
|
for channel in self:
|
|
channel.livechat_start_hour = channel.create_date.hour
|
|
|
|
@api.depends("create_date")
|
|
def _compute_livechat_week_day(self):
|
|
for channel in self:
|
|
channel.livechat_week_day = str(channel.create_date.weekday())
|
|
|
|
def _sync_field_names(self):
|
|
field_names = super()._sync_field_names()
|
|
field_names[None].append(
|
|
Store.One(
|
|
"livechat_operator_id",
|
|
self.env["discuss.channel"]._store_livechat_operator_id_fields(),
|
|
predicate=is_livechat_channel,
|
|
),
|
|
)
|
|
field_names["internal_users"].extend(
|
|
[
|
|
Store.Attr("description", predicate=is_livechat_channel),
|
|
Store.Attr("livechat_note", predicate=is_livechat_channel),
|
|
Store.Attr("livechat_status", predicate=is_livechat_channel),
|
|
Store.Many("livechat_expertise_ids", ["name"], predicate=is_livechat_channel),
|
|
# sudo: internal users having access to the channel can read its tags
|
|
Store.Many(
|
|
"livechat_conversation_tag_ids",
|
|
["name", "color"],
|
|
predicate=is_livechat_channel,
|
|
sudo=True,
|
|
),
|
|
],
|
|
)
|
|
return field_names
|
|
|
|
def _store_livechat_operator_id_fields(self):
|
|
"""Return the standard fields to include in Store for livechat_operator_id."""
|
|
return ["avatar_128", *self.env["res.partner"]._get_store_livechat_username_fields()]
|
|
|
|
def _to_store_defaults(self, target: Store.Target):
|
|
fields = [
|
|
"chatbot_current_step",
|
|
Store.One("country_id", ["code", "name"], predicate=is_livechat_channel),
|
|
Store.One(
|
|
"livechat_lang_id",
|
|
["name"],
|
|
predicate=is_livechat_channel,
|
|
),
|
|
Store.Attr("livechat_end_dt", predicate=is_livechat_channel),
|
|
# sudo - res.partner: accessing livechat operator is allowed
|
|
Store.One(
|
|
"livechat_operator_id",
|
|
self.env["discuss.channel"]._store_livechat_operator_id_fields(),
|
|
predicate=is_livechat_channel,
|
|
sudo=True,
|
|
),
|
|
]
|
|
if target.is_internal(self.env):
|
|
fields.append(
|
|
Store.One(
|
|
"livechat_channel_id", ["name"], predicate=is_livechat_channel, sudo=True
|
|
)
|
|
)
|
|
fields.extend(
|
|
[
|
|
Store.Attr("description", predicate=is_livechat_channel),
|
|
Store.Attr("livechat_note", predicate=is_livechat_channel),
|
|
Store.Attr("livechat_outcome", predicate=is_livechat_channel),
|
|
Store.Attr("livechat_status", predicate=is_livechat_channel),
|
|
Store.Many("livechat_expertise_ids", ["name"], predicate=is_livechat_channel),
|
|
# sudo: internal users having access to the channel can read its tags
|
|
Store.Many(
|
|
"livechat_conversation_tag_ids",
|
|
["name", "color"],
|
|
predicate=is_livechat_channel,
|
|
sudo=True,
|
|
),
|
|
],
|
|
)
|
|
return super()._to_store_defaults(target) + fields
|
|
|
|
def _to_store(self, store: Store, fields):
|
|
"""Extends the channel header by adding the livechat operator and the 'anonymous' profile"""
|
|
super()._to_store(store, [f for f in fields if f != "chatbot_current_step"])
|
|
if "chatbot_current_step" not in fields:
|
|
return
|
|
lang = self.env["chatbot.script"]._get_chatbot_language()
|
|
for channel in self.filtered(lambda channel: channel.chatbot_current_step_id):
|
|
# sudo: chatbot.script.step - returning the current script/step of the channel
|
|
current_step_sudo = channel.chatbot_current_step_id.sudo().with_context(lang=lang)
|
|
chatbot_script = current_step_sudo.chatbot_script_id
|
|
step_message = self.env["chatbot.message"]
|
|
if not current_step_sudo.is_forward_operator:
|
|
step_message = channel.sudo().chatbot_message_ids.filtered(
|
|
lambda m: m.script_step_id == current_step_sudo
|
|
and m.mail_message_id.author_id == chatbot_script.operator_partner_id
|
|
)[:1]
|
|
current_step = {
|
|
"scriptStep": current_step_sudo.id,
|
|
"message": step_message.mail_message_id.id,
|
|
"operatorFound": current_step_sudo.is_forward_operator
|
|
and channel.livechat_operator_id != chatbot_script.operator_partner_id,
|
|
}
|
|
store.add(current_step_sudo)
|
|
store.add(chatbot_script)
|
|
chatbot_data = {
|
|
"script": chatbot_script.id,
|
|
"steps": [current_step],
|
|
"currentStep": current_step,
|
|
}
|
|
store.add(channel, {"chatbot": chatbot_data})
|
|
|
|
@api.autovacuum
|
|
def _gc_empty_livechat_sessions(self):
|
|
hours = 1 # never remove empty session created within the last hour
|
|
self.env.cr.execute("""
|
|
SELECT id as id
|
|
FROM discuss_channel C
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM mail_message M
|
|
WHERE M.res_id = C.id AND m.model = 'discuss.channel'
|
|
) AND C.channel_type = 'livechat' AND livechat_channel_id IS NOT NULL AND
|
|
COALESCE(write_date, create_date, (now() at time zone 'UTC'))::timestamp
|
|
< ((now() at time zone 'UTC') - interval %s)""", ("%s hours" % hours,))
|
|
empty_channel_ids = [item['id'] for item in self.env.cr.dictfetchall()]
|
|
self.browse(empty_channel_ids).unlink()
|
|
|
|
@api.autovacuum
|
|
def _gc_bot_only_ongoing_sessions(self):
|
|
"""Garbage collect bot-only livechat sessions with no activity for over 1 day."""
|
|
stale_sessions = self.search([
|
|
("channel_type", "=", "livechat"),
|
|
("livechat_end_dt", "=", False),
|
|
("last_interest_dt", "<=", "-1d"),
|
|
("livechat_agent_partner_ids", "=", False),
|
|
])
|
|
stale_sessions.livechat_end_dt = fields.Datetime.now()
|
|
|
|
def execute_command_history(self, **kwargs):
|
|
self._bus_send(
|
|
"im_livechat.history_command",
|
|
{"id": self.id, "partner_id": self.env.user.partner_id.id},
|
|
)
|
|
|
|
def _get_visitor_leave_message(self, operator=False, cancel=False):
|
|
return _('Visitor left the conversation.')
|
|
|
|
def _close_livechat_session(self, **kwargs):
|
|
""" Set deactivate the livechat channel and notify (the operator) the reason of closing the session."""
|
|
self.ensure_one()
|
|
if not self.livechat_end_dt:
|
|
member = self.channel_member_ids.filtered(lambda m: m.is_self)
|
|
if member:
|
|
# sudo: discuss.channel.rtc.session - member of current user can leave call
|
|
member.sudo()._rtc_leave_call()
|
|
# sudo: discuss.channel - visitor left the conversation, state must be updated
|
|
self.sudo().livechat_end_dt = fields.Datetime.now()
|
|
Store(bus_channel=self).add(self, "livechat_end_dt").bus_send()
|
|
# avoid useless notification if the channel is empty
|
|
if not self.message_ids:
|
|
return
|
|
# Notify that the visitor has left the conversation
|
|
# sudo: mail.message - posting visitor leave message is allowed
|
|
self.sudo().message_post(
|
|
author_id=self.env.ref('base.partner_root').id,
|
|
body=Markup('<div class="o_mail_notification o_hide_author">%s</div>')
|
|
% self._get_visitor_leave_message(**kwargs),
|
|
message_type='notification',
|
|
subtype_xmlid='mail.mt_comment'
|
|
)
|
|
|
|
# Rating Mixin
|
|
|
|
def _rating_get_parent_field_name(self):
|
|
return 'livechat_channel_id'
|
|
|
|
def _email_livechat_transcript(self, email):
|
|
company = self.env.user.company_id
|
|
tz = "UTC"
|
|
# sudo: discuss.channel - access partner's/guest's timezone
|
|
for customer in self.sudo().livechat_customer_history_ids:
|
|
customer_tz = customer.partner_id.tz or customer.guest_id.timezone
|
|
if customer_tz:
|
|
tz = customer_tz
|
|
break
|
|
render_context = {
|
|
"company": company,
|
|
"channel": self,
|
|
"tz": timezone(tz),
|
|
}
|
|
mail_body = self.env['ir.qweb']._render('im_livechat.livechat_email_template', render_context, minimal_qcontext=True)
|
|
mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body)
|
|
mail = self.env['mail.mail'].sudo().create({
|
|
'subject': _('Conversation with %s', self.livechat_operator_id.user_livechat_username or self.livechat_operator_id.name),
|
|
'email_from': company.catchall_formatted or company.email_formatted,
|
|
'author_id': self.env.user.partner_id.id,
|
|
'email_to': email_split(email)[0],
|
|
'body_html': mail_body,
|
|
})
|
|
mail.send()
|
|
|
|
def _attachment_to_html(self, attachment):
|
|
if attachment.mimetype.startswith("image/"):
|
|
return Markup(
|
|
"<img src='%s?access_token=%s' alt='%s' style='max-width: 75%%; height: auto; padding: 5px;'>",
|
|
) % (
|
|
attachment.image_src,
|
|
attachment.generate_access_token()[0],
|
|
attachment.name,
|
|
)
|
|
file_extension = get_extension(attachment.display_name)
|
|
attachment_data = {
|
|
"id": attachment.id,
|
|
"access_token": attachment.generate_access_token()[0],
|
|
"checksum": attachment.checksum,
|
|
"extension": file_extension.lstrip("."),
|
|
"mimetype": attachment.mimetype,
|
|
"filename": attachment.display_name,
|
|
"url": attachment.url,
|
|
}
|
|
return Markup(
|
|
"<div data-embedded='file' data-oe-protected='true' contenteditable='false' data-embedded-props='%s'/>",
|
|
) % json.dumps({"fileData": attachment_data})
|
|
|
|
def _get_channel_history(self):
|
|
"""
|
|
Converting message body back to plaintext for correct data formatting in HTML field.
|
|
"""
|
|
self.ensure_one()
|
|
parts = []
|
|
previous_message_author = None
|
|
# sudo - mail.message: getting empty/notification messages to exclude them is allowed.
|
|
messages = (
|
|
self.message_ids.sudo().filtered(lambda m: m.message_type != "notification")
|
|
- self.message_ids.sudo()._filter_empty()
|
|
)
|
|
for message in messages.sorted("id"):
|
|
# sudo - res.partner: accessing livechat username or name is allowed to visitor
|
|
message_author = message.author_id.sudo() or message.author_guest_id
|
|
if previous_message_author != message_author:
|
|
parts.append(
|
|
Markup("<br/><strong>%s:</strong><br/>")
|
|
% (
|
|
(message_author.user_livechat_username if message_author._name == "res.partner" else None)
|
|
or message_author.name
|
|
),
|
|
)
|
|
if not tools.is_html_empty(message.body):
|
|
parts.append(Markup("%s<br/>") % html2plaintext(message.body))
|
|
previous_message_author = message_author
|
|
for attachment in message.attachment_ids:
|
|
previous_message_author = message_author
|
|
# sudo - ir.attachment: public user can read attachment metadata
|
|
parts.append(Markup("%s<br/>") % self._attachment_to_html(attachment.sudo()))
|
|
return Markup("").join(parts)
|
|
|
|
def _get_livechat_session_fields_to_store(self):
|
|
return [
|
|
Store.One(
|
|
"livechat_lang_id", ["name"],
|
|
predicate=is_livechat_channel,
|
|
),
|
|
]
|
|
|
|
# =======================
|
|
# Chatbot
|
|
# =======================
|
|
|
|
def _chatbot_find_customer_values_in_messages(self, step_type_to_field):
|
|
"""
|
|
Look for user's input in the channel's messages based on a dictionary
|
|
mapping the step_type to the field name of the model it will be used on.
|
|
|
|
:param dict step_type_to_field: a dict of step types to customer fields
|
|
to fill, like : {'question_email': 'email_from', 'question_phone': 'mobile'}
|
|
"""
|
|
values = {}
|
|
filtered_message_ids = self.chatbot_message_ids.filtered(
|
|
# sudo: chatbot.script.step - getting the type of the current step
|
|
lambda m: m.script_step_id.sudo().step_type in step_type_to_field
|
|
)
|
|
for message_id in filtered_message_ids:
|
|
field_name = step_type_to_field[message_id.script_step_id.step_type]
|
|
if not values.get(field_name):
|
|
values[field_name] = html2plaintext(message_id.user_raw_answer or '')
|
|
|
|
return values
|
|
|
|
def _chatbot_post_message(self, chatbot_script, body):
|
|
""" Small helper to post a message as the chatbot operator
|
|
|
|
:param record chatbot_script
|
|
:param string body: message HTML body """
|
|
# sudo: mail.message - chat bot is allowed to post a message which
|
|
# requires reading its partner among other things.
|
|
return self.with_context(mail_post_autofollow_author_skip=True).sudo().message_post(
|
|
author_id=chatbot_script.sudo().operator_partner_id.id,
|
|
body=body,
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_comment',
|
|
)
|
|
|
|
def _chatbot_validate_email(self, email_address, chatbot_script):
|
|
email_address = html2plaintext(email_address)
|
|
email_normalized = email_normalize(email_address)
|
|
|
|
posted_message = False
|
|
error_message = False
|
|
if not email_normalized:
|
|
error_message = _(
|
|
"'%(input_email)s' does not look like a valid email. Can you please try again?",
|
|
input_email=email_address
|
|
)
|
|
posted_message = self._chatbot_post_message(chatbot_script, plaintext2html(error_message))
|
|
|
|
return {
|
|
'success': bool(email_normalized),
|
|
'posted_message': posted_message,
|
|
'error_message': error_message,
|
|
}
|
|
|
|
def _add_members(
|
|
self,
|
|
*,
|
|
guests=None,
|
|
partners=None,
|
|
users=None,
|
|
create_member_params=None,
|
|
invite_to_rtc_call=False,
|
|
post_joined_message=True,
|
|
inviting_partner=None,
|
|
):
|
|
all_new_members = super()._add_members(
|
|
guests=guests,
|
|
partners=partners,
|
|
users=users,
|
|
create_member_params=create_member_params,
|
|
invite_to_rtc_call=invite_to_rtc_call,
|
|
post_joined_message=post_joined_message,
|
|
inviting_partner=inviting_partner,
|
|
)
|
|
for channel in all_new_members.channel_id:
|
|
# sudo: discuss.channel - accessing livechat_status in internal code is acceptable
|
|
if channel.sudo().livechat_status == "need_help":
|
|
# sudo: discuss.channel - writing livechat_status when a new operator joins is acceptable
|
|
channel.sudo().livechat_status = "in_progress"
|
|
return all_new_members
|
|
|
|
def _message_post_after_hook(self, message, msg_vals):
|
|
"""
|
|
This method is called just before _notify_thread() method which is calling the _to_store()
|
|
method. We need a 'chatbot.message' record before it happens to correctly display the message.
|
|
It's created only if the mail channel is linked to a chatbot step. We also need to save the
|
|
user answer if the current step is a question selection.
|
|
"""
|
|
if self.chatbot_current_step_id and not self.livechat_agent_history_ids:
|
|
selected_answer = (
|
|
self.env["chatbot.script.answer"]
|
|
.browse(self.env.context.get("selected_answer_id"))
|
|
.exists()
|
|
)
|
|
if selected_answer and selected_answer in self.chatbot_current_step_id.answer_ids:
|
|
# sudo - chatbot.message: finding the question message to update the user answer is allowed.
|
|
question_msg = (
|
|
self.env["chatbot.message"]
|
|
.sudo()
|
|
.search(
|
|
[
|
|
("discuss_channel_id", "=", self.id),
|
|
("script_step_id", "=", self.chatbot_current_step_id.id),
|
|
],
|
|
order="id DESC",
|
|
limit=1,
|
|
)
|
|
)
|
|
question_msg.user_script_answer_id = selected_answer
|
|
question_msg.user_raw_script_answer_id = selected_answer.id
|
|
if store := self.env.context.get("message_post_store"):
|
|
store.add(message).add(question_msg.mail_message_id)
|
|
partner, guest = self.env["res.partner"]._get_current_persona()
|
|
Store(bus_channel=partner or guest).add_model_values(
|
|
"ChatbotStep",
|
|
{
|
|
"id": (self.chatbot_current_step_id.id, question_msg.mail_message_id.id),
|
|
"scriptStep": self.chatbot_current_step_id.id,
|
|
"message": question_msg.mail_message_id.id,
|
|
"selectedAnswer": selected_answer.id,
|
|
},
|
|
).bus_send()
|
|
|
|
self.env["chatbot.message"].sudo().create(
|
|
{
|
|
"mail_message_id": message.id,
|
|
"discuss_channel_id": self.id,
|
|
"script_step_id": self.chatbot_current_step_id.id,
|
|
}
|
|
)
|
|
|
|
author_history = self.env["im_livechat.channel.member.history"]
|
|
# sudo - discuss.channel: accessing history to update its state is acceptable
|
|
if message.author_id or message.author_guest_id:
|
|
author_history = self.sudo().livechat_channel_member_history_ids.filtered(
|
|
lambda h: h.partner_id == message.author_id
|
|
if message.author_id
|
|
else h.guest_id == message.author_guest_id
|
|
)
|
|
if author_history:
|
|
if message.message_type not in ("notification", "user_notification"):
|
|
author_history.message_count += 1
|
|
if author_history.livechat_member_type == "agent" and not author_history.response_time_hour:
|
|
author_history.response_time_hour = (
|
|
fields.Datetime.now() - author_history.create_date
|
|
).total_seconds() / 3600
|
|
if not self.livechat_end_dt and author_history.livechat_member_type == "agent":
|
|
self.livechat_failure = "no_failure"
|
|
# sudo: discuss.channel - accessing livechat_status in internal code is acceptable
|
|
if (
|
|
not self.livechat_end_dt
|
|
and self.sudo().livechat_status == "waiting"
|
|
and author_history.livechat_member_type == "visitor"
|
|
):
|
|
# sudo: discuss.channel - writing livechat_status when a message is posted is acceptable
|
|
self.sudo().livechat_status = "in_progress"
|
|
return super()._message_post_after_hook(message, msg_vals)
|
|
|
|
def _chatbot_restart(self, chatbot_script):
|
|
# sudo: discuss.channel - visitor can clear current step to restart the script
|
|
self.sudo().chatbot_current_step_id = False
|
|
# sudo: discuss.channel - visitor can reactivate livechat
|
|
self.sudo().livechat_end_dt = False
|
|
# sudo: chatbot.message - visitor can clear chatbot messages to restart the script
|
|
self.sudo().chatbot_message_ids.unlink()
|
|
return self._chatbot_post_message(
|
|
chatbot_script,
|
|
Markup('<div class="o_mail_notification">%s</div>') % _('Restarting conversation...'),
|
|
)
|
|
|
|
def _get_allowed_channel_member_create_params(self):
|
|
return super()._get_allowed_channel_member_create_params() + [
|
|
"chatbot_script_id",
|
|
"livechat_member_type",
|
|
]
|
|
|
|
def _types_allowing_seen_infos(self):
|
|
return super()._types_allowing_seen_infos() + ["livechat"]
|
|
|
|
def _types_allowing_unfollow(self):
|
|
return super()._types_allowing_unfollow() + ["livechat"]
|
|
|
|
def _action_unfollow(self, partner=None, guest=None, post_leave_message=True):
|
|
super()._action_unfollow(partner, guest, post_leave_message)
|
|
# sudo - discuss.channel: user just left but we need to close the live
|
|
# chat if the last operator left.
|
|
channel_sudo = self.sudo()
|
|
if (
|
|
channel_sudo.channel_type == "livechat"
|
|
and not channel_sudo.livechat_end_dt
|
|
and channel_sudo.member_count == 1
|
|
):
|
|
# sudo: discuss.channel - last operator left the conversation, state must be updated.
|
|
channel_sudo.livechat_end_dt = fields.Datetime.now()
|
|
Store(bus_channel=self).add(channel_sudo, "livechat_end_dt").bus_send()
|
|
|
|
def livechat_join_channel_needing_help(self):
|
|
"""Join a live chat for which help was requested.
|
|
|
|
:returns: Whether the live chat was joined. False if the live chat could not
|
|
be joined because another agent already joined the channel in the meantime.
|
|
:rtype: bool
|
|
"""
|
|
self.ensure_one()
|
|
if self.livechat_status != "need_help":
|
|
return False
|
|
self._add_members(users=self.env.user)
|
|
return True
|
|
|
|
def _forward_human_operator(self, chatbot_script_step=None, users=None):
|
|
""" Add a human operator to the conversation. The conversation with the chatbot (scripted chatbot or ai agent) is stopped
|
|
the visitor will continue the conversation with a real person.
|
|
|
|
In case we don't find any operator (e.g: no-one is available) we don't post any messages.
|
|
The chat with the chatbot will continue normally, which allows to add extra steps when it's the case
|
|
(e.g: ask for the visitor's email and create a lead).
|
|
|
|
:param chatbot_script_step: the forward to operator chatbot script step if the forwarding is done through
|
|
a scripted chatbot (not used if the forwarding is done through an AI Agent).
|
|
:param users: recordset of candidate operators, if not provided the currently available
|
|
users of the livechat channel are used as candidates instead.
|
|
"""
|
|
|
|
human_operator = False
|
|
posted_message = self.env['mail.message']
|
|
if chatbot_script_step is None:
|
|
chatbot_script_step = self.env['chatbot.script.step']
|
|
|
|
if self.livechat_channel_id:
|
|
human_operator = self._get_human_operator(users, chatbot_script_step)
|
|
|
|
# handle edge case where we found yourself as available operator -> don't do anything
|
|
# it will act as if no-one is available (which is fine)
|
|
if human_operator and human_operator != self.env.user:
|
|
|
|
# first post the message of the step (if we have one)
|
|
posted_message = self._post_current_chatbot_step_message(chatbot_script_step)
|
|
|
|
# sudo - discuss.channel: let the chat bot proceed to the forward step (change channel operator, add human operator
|
|
# as member, remove bot from channel, rename channel and finally broadcast the channel to the new operator).
|
|
channel_sudo = self.sudo()
|
|
bot_partner_id = channel_sudo.channel_member_ids.filtered(lambda m: m.livechat_member_type == "bot").partner_id
|
|
|
|
# next, add the human_operator to the channel and post a "Operator invited to the channel" notification
|
|
create_member_params = {'livechat_member_type': 'agent'}
|
|
if chatbot_script_step.operator_expertise_ids:
|
|
create_member_params['agent_expertise_ids'] = chatbot_script_step.operator_expertise_ids.ids
|
|
channel_sudo.livechat_expertise_ids |= chatbot_script_step.operator_expertise_ids
|
|
channel_sudo._add_new_members_to_channel(
|
|
create_member_params=create_member_params,
|
|
inviting_partner=bot_partner_id,
|
|
users=human_operator,
|
|
)
|
|
channel_sudo._action_unfollow(partner=bot_partner_id, post_leave_message=False)
|
|
|
|
# finally, rename the channel to include the operator's name
|
|
channel_sudo._update_forwarded_channel_data(
|
|
livechat_failure="no_answer",
|
|
livechat_operator_id=human_operator.partner_id,
|
|
operator_name=human_operator.livechat_username if human_operator.livechat_username else human_operator.name,
|
|
)
|
|
channel_sudo._add_next_step_message_to_store(chatbot_script_step)
|
|
channel_sudo._broadcast(human_operator.partner_id.ids)
|
|
self.channel_pin(pinned=True)
|
|
else:
|
|
# sudo: discuss.channel - visitor tried getting operator, outcome must be updated
|
|
self.sudo().livechat_failure = "no_agent"
|
|
|
|
return posted_message
|
|
|
|
def _get_human_operator(self, users, chatbot_script_step):
|
|
operator_params = {
|
|
'lang': self.env.context.get("lang"),
|
|
'country_id': self.country_id.id,
|
|
'users': users
|
|
}
|
|
if chatbot_script_step:
|
|
operator_params['expertises'] = chatbot_script_step.operator_expertise_ids
|
|
# sudo: res.users - visitor can access operator of their channel
|
|
human_operator = self.livechat_channel_id.sudo()._get_operator(**operator_params)
|
|
return human_operator
|
|
|
|
def _post_current_chatbot_step_message(self, chatbot_script_step):
|
|
posted_message = self.env['mail.message']
|
|
if chatbot_script_step and chatbot_script_step.message:
|
|
posted_message = self._chatbot_post_message(chatbot_script_step.chatbot_script_id, chatbot_script_step.message)
|
|
return posted_message
|
|
|
|
def _add_new_members_to_channel(self, create_member_params, inviting_partner, users=None, partners=None):
|
|
member_params = {
|
|
'create_member_params': create_member_params,
|
|
'inviting_partner': inviting_partner
|
|
}
|
|
if users:
|
|
member_params['users'] = users
|
|
if partners:
|
|
member_params['partners'] = partners
|
|
self._add_members(**member_params)
|
|
|
|
def _update_forwarded_channel_data(self, /, *, livechat_failure, livechat_operator_id, operator_name):
|
|
self.write(
|
|
{
|
|
"livechat_failure": livechat_failure,
|
|
"livechat_operator_id": livechat_operator_id,
|
|
"name": " ".join(
|
|
[
|
|
self.env.user.display_name
|
|
if not self.env.user._is_public()
|
|
else self.sudo().self_member_id.guest_id.name,
|
|
operator_name
|
|
]
|
|
)
|
|
}
|
|
)
|
|
|
|
def _add_next_step_message_to_store(self, chatbot_script_step):
|
|
if chatbot_script_step:
|
|
step_message = next((
|
|
# sudo - chatbot.message.id: visitor can access chat bot messages.
|
|
m.mail_message_id for m in self.sudo().chatbot_message_ids.sorted("id")
|
|
if m.script_step_id == chatbot_script_step
|
|
and m.mail_message_id.author_id == chatbot_script_step.chatbot_script_id.operator_partner_id
|
|
), self.env["mail.message"])
|
|
Store(bus_channel=self).add_model_values(
|
|
"ChatbotStep",
|
|
{
|
|
"id": (chatbot_script_step.id, step_message.id),
|
|
"scriptStep": chatbot_script_step.id,
|
|
"message": step_message.id,
|
|
"operatorFound": True,
|
|
},
|
|
).bus_send()
|