Add support for emails on the chat channels

This commit is contained in:
Nicolas Évrard
2026-03-31 18:59:55 +02:00
parent 6a38874d57
commit 44642bec77
22 changed files with 1146 additions and 139 deletions
+1
View File
@@ -1,3 +1,4 @@
* Add an action to handle chat replies
* Add support for Python 3.14
* Remove support for Python 3.9
@@ -5,6 +5,19 @@ Configuration
The *Inbound Email Module* uses values from settings in the ``[inbound_email]``
section of the :ref:`trytond:topics-configuration`.
.. _config-inbound_email.chat_reply_to:
``chat_reply_to``
=================
``chat_reply_to`` defines the email address used in the ``Reply-To`` header
when sending a message from a chat channel.
Tryton uses `sub-addressing`_ to discriminate between channels.
.. _`sub-addressing`: https://en.wikipedia.org/wiki/Email_address#Sub-addressing
Default: To the value of :ref:`trytond:config-email.from`.
.. _config-inboud_email.filestore:
``filestore``
+1
View File
@@ -255,6 +255,7 @@ class Rule(sequence_ordered(), ModelSQL, ModelView):
action = fields.Selection([
(None, ""),
('ir.chat.channel|post_inbound_email', "Post to Channel"),
], "Action")
@classmethod
+79
View File
@@ -0,0 +1,79 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import email.parser
import email.policy
import itertools
import re
from email.utils import getaddresses
import trytond.config as config
from trytond.pool import Pool, PoolMeta
from trytond.transaction import Transaction
REPLY_LINE = '\N{EM DASH}' * 80
DESTINATION_RE = re.compile(r"^[^@]*\+([0-9a-f]{32})@", re.IGNORECASE)
def _get_channel_identifier(email):
base = config.get(
'inbound_email', 'chat_reply_to',
default=config.get('email', 'from'))
_, our_domain = base.split('@', 1)
for key in ('To', 'Cc'):
if key not in email:
continue
addresses = [a for _, a in getaddresses(email.get_all(key))]
for address in addresses:
_, domain = address.split('@', 1)
if domain != our_domain:
continue
if (m := DESTINATION_RE.match(address)):
channel_id, = m.groups()
return channel_id
return None
class Channel(metaclass=PoolMeta):
__name__ = 'ir.chat.channel'
@classmethod
def _email_channel(cls, email):
channels = []
if (channel_id := _get_channel_identifier(email)):
channels = cls.search([('identifier', '=', channel_id)])
return None if len(channels) != 1 else channels[0]
@classmethod
def _email_content(cls, body):
return '\n'.join(itertools.takewhile(
lambda l: REPLY_LINE not in l,
body.splitlines()))
@classmethod
def post_inbound_email(cls, inbound_email):
parser = email.parser.BytesParser(policy=email.policy.default)
message = parser.parsebytes(inbound_email.data)
return cls.post_from_email(message)
@classmethod
def _email_reply_to(cls, message):
base = config.get(
'inbound_email', 'chat_reply_to',
default=config.get('email', 'from'))
local_part, domain_part = base.split('@', 1)
if '+' in local_part:
local_part, _ = local_part.split('+', 1)
local_part += f'+{message.channel.identifier}'
return '@'.join((local_part, domain_part))
@classmethod
def _email_body(cls, message):
pool = Pool()
Message = pool.get('ir.message')
ModelData = pool.get('ir.model.data')
with Transaction().set_context(language=message.channel.language):
above_msg = Message(ModelData.get_id('ir', 'msg_reply_above'))
return f'{REPLY_LINE}\n{above_msg.text}\n\n{message.content}'
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data>
<record model="ir.message" id="msg_reply_above">
<field name="text">Please reply above the line.</field>
</record>
</data>
</tryton>
@@ -6,6 +6,9 @@ import uuid
from email.message import EmailMessage
from unittest.mock import patch
import trytond.config as config
from trytond.modules.inbound_email.ir import (
REPLY_LINE, _get_channel_identifier)
from trytond.pool import Pool
from trytond.protocols.wrappers import HTTPStatus
from trytond.tests.test_tryton import (
@@ -17,6 +20,16 @@ class InboundEmailTestCase(ModuleTestCase):
"Test Inbound Email module"
module = 'inbound_email'
@classmethod
def setUpClass(cls):
super().setUpClass()
email_from = config.get('email', 'from', default='')
config.set('email', 'from', 'tryton@example.org')
cls.addClassCleanup(config.set, 'email', 'from', email_from)
config.add_section('inbound_email')
config.set('inbound_email', 'chat_reply_to', 'chat@example.org')
cls.addClassCleanup(config.remove_section, 'inbound_email')
def get_message(self):
message = EmailMessage()
message['From'] = "John Doe <john.doe@example.com>"
@@ -303,6 +316,40 @@ class InboundEmailTestCase(ModuleTestCase):
func.assert_called_once_with(email, rule)
self.assertEqual(email.result, result)
def test_get_channel_identifier(self):
"Test parsing the recipients to find the email channel"
channel_id = uuid.uuid4().hex
for value, expected in [
(f'chat+{channel_id}@example.org', channel_id),
(['chat@example.org', f'chat+{channel_id}@example.org'],
channel_id),
('chat@example.org', None),
('chat+foo@example.org', None),
(f'{channel_id}@example.org', None),
(f'chat+{channel_id}@example.com', None),
]:
for recipient_hdr in ('To', 'Cc'):
with self.subTest(f"{recipient_hdr}: {value}"):
msg = EmailMessage()
msg[recipient_hdr] = value
self.assertEqual(_get_channel_identifier(msg), expected)
@with_transaction()
def test_find_email_content(self):
"Test finding the content of the reply"
pool = Pool()
Channel = pool.get('ir.chat.channel')
for (body, expected) in [
("Foo", "Foo"),
("Foo\n-----\nBar", "Foo\n-----\nBar"),
(f"Foo\n{REPLY_LINE}\nLorem Ipsum", "Foo"),
(f"Foo\n> {REPLY_LINE}\n> Lorem Ipsum", "Foo"),
]:
with self.subTest(body):
self.assertEqual(Channel._email_content(body), expected)
class InboundEmailRouteTestCase(RouteTestCase):
"Test Inbound Email route"
+2
View File
@@ -5,6 +5,7 @@ depends:
res
xml:
inbound_email.xml
message.xml
[register]
model:
@@ -12,3 +13,4 @@ model:
inbound_email.Email
inbound_email.Rule
inbound_email.RuleHeader
ir.Channel
+54 -1
View File
@@ -2,7 +2,7 @@
# this repository contains the full copyright notices and license terms.
from trytond.model import fields
from trytond.pool import Pool, PoolMeta
from trytond.tools import escape_wildcard
from trytond.tools import escape_wildcard, grouped_slice
from trytond.transaction import Transaction
@@ -101,3 +101,56 @@ class EmailTemplate(metaclass=PoolMeta):
elif record.party.lang:
language = record.party.lang
return language
class Channel(metaclass=PoolMeta):
__name__ = 'ir.chat.channel'
@classmethod
def _get_followers(cls, resource):
pool = Pool()
Party = pool.get('party.party')
ContactMechanism = pool.get('party.contact_mechanism')
followers = super()._get_followers(resource)
has_avatar = 'avatar_url' in Party._fields
for emails in grouped_slice([e for t, e in followers if t == 'email']):
contacts = ContactMechanism.search([
('type', '=', 'email'),
('value', 'in', list(emails)),
])
followers |= {('email', c.value): {
'type': 'email',
'key': c.value,
'name': f'{c.name}',
'avatar_url': c.party.avatar_url if has_avatar else None,
} for c in contacts}
return followers
@classmethod
def _search_followers(cls, resource, text):
pool = Pool()
Party = pool.get('party.party')
ContactMechanism = pool.get('party.contact_mechanism')
followers = super()._search_followers(resource, text)
has_avatar = 'avatar_url' in Party._fields
contacts = ContactMechanism.search([
'OR',
[
('rec_name', 'ilike', '{text}%'),
('type', '=', 'email'),
],
('name', 'ilike', f'%{text}%'),
])
return followers | {
('email', c.value): {
'type': 'email',
'key': c.value,
'name': f'{c.name}',
'avatar_url': c.party.avatar_url if has_avatar else None,
}
for c in contacts}
+1
View File
@@ -34,6 +34,7 @@ model:
configuration.ConfigurationLang
ir.Email
ir.EmailTemplate
ir.Channel
wizard:
party.CheckVIES
party.Replace
+232 -59
View File
@@ -3,6 +3,16 @@
(function() {
'use strict';
function add_size(url, size=32) {
if (url) {
url = new URL(url, window.location);
url.searchParams.set('s', size);
return url.href;
} else {
return '';
}
}
class _Chat {
constructor(record) {
this.notify = this.notify.bind(this);
@@ -46,63 +56,221 @@
});
}
_get_followers() {
return Sao.rpc({
'method': 'model.ir.chat.channel.get_followers',
'params': [this.record, {}],
}, Sao.Session.current_session);
}
_search_followers(text) {
if (!text) {
return jQuery.when();
}
return Sao.rpc({
'method': 'model.ir.chat.channel.search_followers',
'params': [this.record, text, {}],
}, Sao.Session.current_session).then(followers => {
return followers.filter(e => {
return !((e.type == 'user') &&
(e.key == Sao.Session.current_session.login));
});
});
}
_add_follower(follower) {
let method;
switch (follower.type) {
case 'user':
method = 'subscribe';
break;
case 'email':
method = 'subscribe_email';
break;
}
Sao.rpc({
'method': `model.ir.chat.channel.${method}`,
'params': [this.record, follower.key, {}],
}, Sao.Session.current_session);
}
_remove_follower(follower) {
let method;
switch (follower.type) {
case 'user':
method = 'unsubscribe';
break;
case 'email':
method = 'unsubscribe_email';
break;
}
Sao.rpc({
'method': `model.ir.chat.channel.${method}`,
'params': [this.record, follower.key, {}],
}, Sao.Session.current_session);
}
__build() {
let el = jQuery('<div/>', {
'class': 'chat',
});
let btn_group = jQuery('<div/>', {
'class': 'btn-group',
'role': 'group',
let toolbar = jQuery('<div/>', {
'class': 'btn-toolbar',
'role': 'toolbar',
}).appendTo(el);
let input_group = jQuery('<div/>', {
'class': 'input-group',
'role': 'group',
}).appendTo(toolbar);
this.subscribe_input = jQuery('<input/>', {
'class': 'form-control input-sm',
'placeholder': Sao.i18n.gettext("Add a follower"),
'title': Sao.i18n.gettext("Subscribe a follower to this channel"),
}).appendTo(input_group);
let avatar_size = 32;
let format = function(val) {
return jQuery('<div/>', {
'title': val.key,
})
.append(jQuery('<img/>', {
'src': add_size(val.avatar_url, avatar_size),
'class': 'img-circle',
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
}))
.append(jQuery('<span/>', {
})
.text(val.name));
};
new Sao.common.InputCompletion(
input_group, this._search_followers.bind(this),
(follower) => {
this._add_follower(follower);
this.subscribe_input.val('');
this.subscribe_input.focus();
}, format);
let dropdown = jQuery('<div/>', {
'class': 'btn-group dropdown',
'role': 'group',
}).appendTo(toolbar);
let subscribe_btn = jQuery('<button/>', {
'class': 'btn btn-default pull-right',
'type': 'button',
'title': Sao.i18n.gettext("Toggle notification"),
'class': 'btn btn-default pull-right dropdown-toggle',
'data-toggle': 'dropdown',
'aria-expanded': false,
'aria-haspopup': true,
'title': Sao.i18n.gettext("Show followers"),
}).append(Sao.common.ICONFACTORY.get_icon_img(
'tryton-notification'))
.appendTo(btn_group);
.appendTo(dropdown);
subscribe_btn.uniqueId();
Sao.rpc({
'method': 'model.ir.chat.channel.get_followers',
'params': [this.record, {}],
}, Sao.Session.current_session).then((followers) => {
set_subscribe_state(~followers.users.indexOf(
Sao.Session.current_session.login));
})
this._get_followers().then((followers) => {
let subscribed = followers.some((e) => (
(e.type == 'user') &&
(e.key == Sao.Session.current_session.login)));
set_subscribe_state(subscribed);
});;
let set_subscribe_state = (subscribed) => {
let img;
if (subscribed) {
img = 'tryton-notification-on';
subscribe_btn.addClass('active').off().click(unsubscribe);
subscribe_btn.addClass('active');
} else {
img = 'tryton-notification-off';
subscribe_btn.removeClass('active').off().click(subscribe);
subscribe_btn.removeClass('active');
}
subscribe_btn.html(Sao.common.ICONFACTORY.get_icon_img(img));
}
let subscribe = () => {
let session = Sao.Session.current_session;
Sao.rpc({
'method': 'model.ir.chat.channel.subscribe',
'params': [this.record, {}],
}, session).then(() => {
set_subscribe_state(true);
})
};
let menu = jQuery('<ul/>', {
'class': 'dropdown-menu dropdown-menu-right',
'role': 'menu',
'aria-labelledby': subscribe_btn.id,
}).appendTo(dropdown);
let unsubscribe = () => {
let session = Sao.Session.current_session;
Sao.rpc({
'method': 'model.ir.chat.channel.unsubscribe',
'params': [this.record, {}],
}, session).then(() => {
set_subscribe_state(false);
})
};
dropdown.on('show.bs.dropdown', () => {
menu.empty();
let user_action;
if (subscribe_btn.hasClass('active')) {
user_action = jQuery('<a/>', {
'href': '#',
'class': 'text-danger text-uppercase',
}).text(Sao.i18n.gettext("Unsubscribe"))
.click(() => {
Sao.rpc({
'method': 'model.ir.chat.channel.unsubscribe',
'params': [this.record, {}],
}, Sao.Session.current_session).then(() => {
set_subscribe_state(false);
});
});
} else {
user_action = jQuery('<a/>', {
'href': '#',
'class': 'text-primary text-uppercase',
}).text(Sao.i18n.gettext("Subscribe"))
.click(() => {
Sao.rpc({
'method': 'model.ir.chat.channel.subscribe',
'params': [this.record, {}],
}, Sao.Session.current_session).then(() => {
set_subscribe_state(true);
});
});
}
jQuery('<li/>', {
'role': 'presentation',
}).append(user_action).appendTo(menu);
this._get_followers().then((followers) => {
followers = followers.filter(e => {
return !((e.type == 'user') &&
(e.key == Sao.Session.current_session.login));
});
if (followers.length) {
jQuery('<li/>', {
'class': 'divider',
}).appendTo(menu);
}
let add_follower = (follower) => {
jQuery('<li/>', {
'role': 'presentation',
})
.append(jQuery('<div/>', {
'title': follower.key,
})
.append(jQuery('<img/>', {
'class': 'img-circle chat-avatar',
'src': add_size(follower.avatar_url, avatar_size),
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
}))
.append(jQuery('<span/>')
.text(follower.name))
.append(jQuery('<button/>', {
'class': 'btn btn-link',
'title': Sao.i18n.gettext("Unsubscribe"),
'type': 'button',
}).append("&times;").click((evt) => {
Sao.common.sur.run(
Sao.i18n.gettext(
'Are you sure to unsubscribe "%1" from this channel?',
follower.name)
).then(() => {
this._remove_follower(follower);
});
}))
).appendTo(menu);
};
followers.forEach(add_follower);
});
});
this._messages = jQuery('<div/>', {
'class': 'chat-messages',
@@ -167,35 +335,40 @@
}
create_message(message) {
let avatar_size = 32;
let timestamp = Sao.common.format_datetime(
Sao.common.date_format() + ' %X', message.timestamp);
let avatar_url = '';
if (message.avatar_url) {
let url = new URL(message.avatar_url, window.location);
url.searchParams.set('s', avatar_size);
avatar_url = url.href;
if (message.user) {
let avatar_size = 32;
let avatar_url = add_size(message.avatar_url, avatar_size);
return jQuery('<div/>', {
'class': 'media chat-message',
}).append(jQuery('<div/>', {
'class': 'media-left',
}).append(jQuery('<img/>', {
'class': 'media-object img-circle chat-avatar',
'src': avatar_url,
'alt': message.author,
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
}))).append(jQuery('<div/>', {
'class': 'media-body well well-sm',
}).append(jQuery('<h6/>', {
'class': 'media-heading',
}).text(message.author)
.append(jQuery('<small/>', {
'class': 'text-muted pull-right',
}).text(timestamp)))
.append(jQuery('<div/>', {
'class': `chat-content chat-content-${message.audience}`,
}).text(message.content)));
} else {
return jQuery('<div/>', {
'class': 'media chat-message system-message',
}).append(jQuery('<div/>', {
'class': 'chat-content',
'title': timestamp,
}).text(message.content));
}
return jQuery('<div/>', {
'class': 'media chat-message',
}).append(jQuery('<div/>', {
'class': 'media-left',
}).append(jQuery('<img/>', {
'class': 'media-object img-circle chat-avatar',
'src': avatar_url,
'alt': message.author,
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
}))).append(jQuery('<div/>', {
'class': 'media-body well well-sm',
}).append(jQuery('<h6/>', {
'class': 'media-heading',
}).text(message.author)
.append(jQuery('<small/>', {
'class': 'text-muted pull-right',
}).text(timestamp)))
.append(jQuery('<div/>', {
'class': `chat-content chat-content-${message.audience}`,
}).text(message.content)));
}
}
Sao.Chat = _Chat;
+43
View File
@@ -520,6 +520,40 @@ html[theme="default"] {
padding-left: 5px;
padding-right: 5px;
.btn-toolbar {
display: flex;
.input-group {
flex: 1;
.img-circle {
height: 2em;
margin-inline-end: 1ex;
}
}
ul.dropdown-menu {
width: 20em;
div {
display: flex;
margin: 1ex;
gap: 5px;
img {
align-self: center;
width: 32px;
height: 32px;
}
span {
flex: 1;
align-self: center;
}
}
}
}
.chat-messages-outer {
display: flex;
flex-direction: column;
@@ -535,6 +569,15 @@ html[theme="default"] {
.chat-message {
margin-top: 5px;
&.system-message {
align-items: center;
color: @text-muted;
display: flex;
font-size: small;
justify-content: center;
padding: 0 2ex;
}
}
.chat-content {
+221 -50
View File
@@ -2,10 +2,11 @@
# this repository contains the full copyright notices and license terms.
import gettext
from gi.repository import Gdk, Gio, GObject, Gtk
from gi.repository import Gdk, Gio, GLib, GObject, Gtk
from tryton import common, rpc
from tryton.bus import Bus
from tryton.common import sur
_ = gettext.gettext
@@ -48,6 +49,12 @@ class MessageList(GObject.GObject, Gio.ListModel):
self.emit('items-changed', len(self._messages) - 1, 0, 1)
def _follower_self(follower):
return (
follower['type'] == 'user'
and follower['key'] == rpc._LOGIN)
class Chat:
def __init__(self, record):
@@ -89,24 +96,21 @@ class Chat:
widget = Gtk.VBox()
widget.set_spacing(3)
hbuttonbox = Gtk.HButtonBox()
hbuttonbox.set_layout(Gtk.ButtonBoxStyle.END)
widget.pack_start(hbuttonbox, expand=False, fill=True, padding=0)
hbox = Gtk.HBox()
widget.pack_start(hbox, expand=False, fill=True, padding=0)
subscribe_btn = Gtk.ToggleButton()
subscribe_entry = self.__build_follower_entry()
hbox.pack_start(subscribe_entry, expand=True, fill=True, padding=0)
subscribe_btn = Gtk.MenuButton()
subscribe_btn.set_image(common.IconFactory.get_image(
'tryton-notification', Gtk.IconSize.SMALL_TOOLBAR))
tooltips.set_tip(subscribe_btn, _("Toggle notification"))
tooltips.set_tip(subscribe_btn, _("Show followers"))
subscribe_btn.set_relief(Gtk.ReliefStyle.NONE)
hbuttonbox.pack_start(
hbox.pack_start(
subscribe_btn, expand=False, fill=True, padding=0)
hbuttonbox.set_child_non_homogeneous(subscribe_btn, True)
followers = rpc.execute(
'model', 'ir.chat.channel', 'get_followers', self.record,
rpc.CONTEXT)
def set_subscribe_state(subscribed):
def set_subscribe_state():
if subscribed:
img = 'tryton-notification-on'
else:
@@ -114,22 +118,86 @@ class Chat:
subscribe_btn.set_image(common.IconFactory.get_image(
img, Gtk.IconSize.SMALL_TOOLBAR))
subscribed = rpc._LOGIN in followers['users']
set_subscribe_state(subscribed)
subscribe_btn.set_active(subscribed)
subscribed = any(_follower_self(f) for f in self._get_followers())
set_subscribe_state()
followers_grid = Gtk.Grid()
followers_grid.set_column_spacing(2)
followers_grid.set_row_spacing(2)
subscribe_popover = Gtk.Popover()
subscribe_btn.set_popover(subscribe_popover)
subscribe_popover.add(followers_grid)
def unsubscribe(follower):
def do_unsubscribe(btn):
confirmation_text = _('Are you sure to unsubscribe "%(name)s"'
' from this channel?') % follower
if sur(confirmation_text):
if follower['type'] == 'user':
method = 'unsubscribe'
elif follower['type'] == 'email':
method = 'unsubscribe_email'
rpc.execute(
'model', 'ir.chat.channel', method,
self.record, follower['key'], rpc.CONTEXT)
subscribe_popover.popdown()
return do_unsubscribe
def fill_followers(btn):
nonlocal subscribed
for child in followers_grid.get_children():
followers_grid.remove(child)
followers = self._get_followers()
subscribed = any(_follower_self(f) for f in self._get_followers())
if subscribed:
action_txt = _("Unsubscribe")
else:
action_txt = _("Subscribe")
followers_grid.attach(
(b := Gtk.Button(action_txt)), 0, 0, 5, 1)
b.set_relief(Gtk.ReliefStyle.NONE)
b.connect('clicked', toggle_subscribe)
followers = filter(lambda f: not _follower_self(f), followers)
for idx, follower in enumerate(followers, start=1):
if follower['avatar_url']:
image = Gtk.Image()
pixbuf = common.IconFactory.get_pixbuf_url(
follower['avatar_url'], size=16, size_param='s',
callback=image.set_from_pixbuf)
image.set_from_pixbuf(pixbuf)
followers_grid.attach(image, 0, idx, 1, 1)
followers_grid.attach(
Gtk.Label(follower['name'], halign=Gtk.Align.START),
1, idx, 3, 1)
followers_grid.attach((b := Gtk.Button('×')), 4, idx, 1, 1)
b.set_relief(Gtk.ReliefStyle.NONE)
b.connect('clicked', unsubscribe(follower))
subscribe_popover.show_all()
def toggle_subscribe(button):
if button.props.active:
rpc.execute(
'model', 'ir.chat.channel', 'subscribe', self.record,
rpc.CONTEXT)
else:
nonlocal subscribed
if subscribed:
rpc.execute(
'model', 'ir.chat.channel', 'unsubscribe', self.record,
rpc.CONTEXT)
set_subscribe_state(button.props.active)
else:
rpc.execute(
'model', 'ir.chat.channel', 'subscribe', self.record,
rpc.CONTEXT)
subscribed = not subscribed
set_subscribe_state()
if subscribed:
action_txt = _("Unsubscribe")
else:
action_txt = _("Subscribe")
button.set_label(action_txt)
subscribe_btn.connect('toggled', toggle_subscribe)
subscribe_btn.connect('clicked', fill_followers)
def _submit(button):
buffer = input_.get_buffer()
@@ -177,7 +245,97 @@ class Chat:
return widget
def _get_followers(self):
return rpc.execute(
'model', 'ir.chat.channel', 'get_followers', self.record,
rpc.CONTEXT)
def _search_followers(self, text):
followers = rpc.execute(
'model', 'ir.chat.channel', 'search_followers',
str(self.record), text, rpc.CONTEXT)
return filter(lambda f: not _follower_self(f), followers)
def _add_follower(self, follower):
if follower['type'] == 'user':
rpc.execute(
'model', 'ir.chat.channel', 'subscribe',
self.record, follower['key'], rpc.CONTEXT)
elif follower['type'] == 'email':
rpc.execute(
'model', 'ir.chat.channel', 'subscribe_email',
self.record, follower['key'], rpc.CONTEXT)
def __build_follower_entry(self):
tooltips = common.Tooltips()
subscribe_entry = Gtk.Entry()
subscribe_entry.set_placeholder_text(_("Add a follower"))
tooltips.set_tip(
subscribe_entry, _("Subscribe a follower to this channel"))
def text_setter(layout, cell, store, iter_):
cell.set_property('text', store[iter_][0]['name'])
def pixbuf_setter(layout, cell, store, iter_):
avatar_url = store[iter_][0]['avatar_url']
if avatar_url:
pixbuf = common.IconFactory.get_pixbuf_url(
avatar_url, size=16, size_param='s')
else:
pixbuf = None
cell.set_property('pixbuf', pixbuf)
model = Gtk.ListStore(GObject.TYPE_PYOBJECT)
completion = Gtk.EntryCompletion()
completion.set_match_func(lambda *a: True)
completion.set_property('popup_set_width', True)
completion.set_model(model)
completion.pack_start(
(pixbuf_rdr := Gtk.CellRendererPixbuf()), expand=False)
completion.set_cell_data_func(pixbuf_rdr, pixbuf_setter)
completion.pack_start(
text_rdr := (Gtk.CellRendererText()), expand=True)
completion.set_cell_data_func(text_rdr, text_setter)
subscribe_entry.set_completion(completion)
def update(entry, search_text):
if search_text != entry.get_text():
return
if not search_text or not model:
model.clear()
model.search_text = search_text
return
if getattr(model, 'search_text', None) == search_text:
return
followers = self._search_followers(search_text)
model.clear()
for follower in followers:
model.append([follower])
model.search_text = search_text
entry.emit('changed')
def changed(entry):
search_text = entry.get_text()
GLib.timeout_add(300, update, entry, search_text)
subscribe_entry.connect('changed', changed)
def match_selected(completion, model, iter_):
self._add_follower(model[iter_][0])
subscribe_entry.set_text('')
subscribe_entry.grab_focus()
completion.connect('match-selected', match_selected)
return subscribe_entry
def create_message(self, item):
tooltips = common.Tooltips()
message = item.message
row = Gtk.ListBoxRow()
@@ -187,37 +345,50 @@ class Chat:
hbox = Gtk.HBox(spacing=10)
row.add(hbox)
if avatar_url := message.get('avatar_url'):
image = Gtk.Image()
pixbuf = common.IconFactory.get_pixbuf_url(
avatar_url, size=32, size_param='s',
callback=image.set_from_pixbuf)
image.set_from_pixbuf(pixbuf)
image.set_valign(Gtk.Align.START)
hbox.pack_start(image, False, False, 0)
timestamp = message['timestamp'].strftime('%x %X')
bubble = Gtk.VBox()
hbox.pack_end(bubble, True, True, 0)
if message.get('user'):
if avatar_url := message.get('avatar_url'):
image = Gtk.Image()
pixbuf = common.IconFactory.get_pixbuf_url(
avatar_url, size=32, size_param='s',
callback=image.set_from_pixbuf)
image.set_from_pixbuf(pixbuf)
image.set_valign(Gtk.Align.START)
hbox.pack_start(image, False, False, 0)
author = Gtk.Label(label=message['author'])
author.set_xalign(0)
author.get_style_context().add_class('dim')
timestamp = Gtk.Label(label=message['timestamp'].strftime('%x %X'))
timestamp.set_xalign(1)
timestamp.get_style_context().add_class('dim')
bubble = Gtk.VBox()
hbox.pack_end(bubble, True, True, 0)
meta = Gtk.HBox()
meta.pack_start(author, True, True, 0)
meta.pack_end(timestamp, False, False, 0)
author = Gtk.Label(label=message['author'])
author.set_xalign(0)
author.get_style_context().add_class('dim')
content = Gtk.Label(label=message['content'])
content.set_xalign(0)
content.set_line_wrap(True)
content.set_selectable(True)
content.get_style_context().add_class(
f"chat-content-{message['audience']}")
timestamp = Gtk.Label(label=timestamp)
timestamp.set_xalign(1)
timestamp.get_style_context().add_class('dim')
bubble.pack_start(meta, False, False, 0)
bubble.pack_start(content, False, False, 0)
meta = Gtk.HBox()
meta.pack_start(author, True, True, 0)
meta.pack_end(timestamp, False, False, 0)
content = Gtk.Label(label=message['content'])
content.set_xalign(0)
content.set_line_wrap(True)
content.set_selectable(True)
content.get_style_context().add_class(
f"chat-content-{message['audience']}")
bubble.pack_start(meta, False, False, 0)
bubble.pack_start(content, False, False, 0)
else:
content = Gtk.Label(label=message['content'])
content.set_xalign(.5)
content.set_line_wrap(True)
content.set_selectable(True)
content.get_style_context().add_class('dim')
hbox.pack_start(content, True, True, 0)
tooltips.set_tip(content, timestamp)
return row
+1
View File
@@ -1,3 +1,4 @@
* Send emails on chat messages
* Add path attribute on XML fields to specfiy a relative path value
* Allow to include subdirectories in tryton.cfg
* Add route for login / logout with cookie
+253 -28
View File
@@ -2,27 +2,37 @@
# this repository contains the full copyright notices and license terms.
import json
import uuid
from email.message import EmailMessage
from email.utils import getaddresses, make_msgid
from operator import itemgetter
from sql import Null
from sql.conditionals import NullIf
import trytond.config as config
from trytond.bus import Bus
from trytond.i18n import gettext
from trytond.model import ChatMixin, Check, ModelSQL, Unique, fields
from trytond.model import ChatMixin, Check, ModelSQL, ModelView, Unique, fields
from trytond.model.exceptions import ValidationError
from trytond.pool import Pool
from trytond.pyson import Bool, Eval
from trytond.rpc import RPC
from trytond.tools import firstline
from trytond.sendmail import send_message_transactional
from trytond.tools import cached_property, firstline
from trytond.tools.email_ import (
EmailNotValidError, normalize_email, validate_email)
EmailNotValidError, normalize_email, set_from_header, validate_email)
from trytond.transaction import Transaction
from trytond.url import host
FOLLOWER_SEARCH_LIMIT = 20
class InvalidEMailError(ValidationError):
pass
class Channel(ModelSQL):
class Channel(ModelSQL, ModelView):
"Chat Channel"
__name__ = 'ir.chat.channel'
@@ -30,6 +40,7 @@ class Channel(ModelSQL):
"Resource", selection='get_models', required=True)
followers = fields.One2Many(
'ir.chat.follower', 'channel', "Followers")
identifier = fields.Char("Identifier", readonly=True)
@classmethod
def __setup__(cls):
@@ -38,6 +49,8 @@ class Channel(ModelSQL):
cls._sql_constraints += [
('resource_unique', Unique(t, t.resource),
'ir.msg_chat_channel_resource_unique'),
('identifier_unique', Unique(t, t.identifier),
'ir.msg_chat_channel_identifier_unique'),
]
cls.__rpc__.update(
subscribe=RPC(readonly=False),
@@ -45,10 +58,33 @@ class Channel(ModelSQL):
subscribe_email=RPC(readonly=False),
unsubscribe_email=RPC(readonly=False),
get_followers=RPC(),
search_followers=RPC(),
post=RPC(readonly=False, result=int),
get_models=RPC(),
get=RPC(),
)
cls._buttons.update({
'reset_identifier': {
'icon': 'tryton-refresh',
},
})
@classmethod
def preprocess_values(cls, mode, values):
values = super().preprocess_values(mode, values)
if mode == 'create' and 'identifier' not in values:
values['identifier'] = uuid.uuid4().hex
return values
@cached_property
def language(self):
pool = Pool()
Configuration = pool.get('ir.configuration')
language = self.resource.chat_language(audience='public')
if language is None:
language = Configuration.get_language()
return language
@classmethod
def get_models(cls):
@@ -83,9 +119,14 @@ class Channel(ModelSQL):
@classmethod
def subscribe(cls, resource, username=None):
cls.check_access(resource)
pool = Pool()
Follower = pool.get('ir.chat.follower')
User = pool.get('res.user')
Message = pool.get('ir.message')
ModelData = pool.get('ir.model.data')
if username is not None:
user, = User.search([
('login', '=', username),
@@ -95,48 +136,126 @@ class Channel(ModelSQL):
channel = cls._get_channel(resource)
Follower.add_user(channel, user)
with Transaction().set_context(
language=channel.language, user=0, _notify=False):
msg = Message(ModelData.get_id('ir', 'msg_chat_follower_joined'))
cls.post(resource, msg.text % {'name': user.login})
@classmethod
def unsubscribe(cls, resource):
def unsubscribe(cls, resource, username=None):
pool = Pool()
Follower = pool.get('ir.chat.follower')
User = pool.get('res.user')
user = User(Transaction().user)
Message = pool.get('ir.message')
ModelData = pool.get('ir.model.data')
if username:
cls.check_access(resource)
try:
user, = User.search([('login', '=', username)], limit=1)
except ValueError:
return None
else:
user = User(Transaction().user)
channel = cls._get_channel(resource)
Follower.remove_user(channel, user)
with Transaction().set_context(
language=channel.language, user=0, _notify=False):
msg = Message(ModelData.get_id('ir', 'msg_chat_follower_left'))
cls.post(resource, msg.text % {'name': user.login})
@classmethod
def subscribe_email(cls, resource, email):
cls.check_access(resource)
pool = Pool()
Follower = pool.get('ir.chat.follower')
Message = pool.get('ir.message')
ModelData = pool.get('ir.model.data')
channel = cls._get_channel(resource)
Follower.add_email(channel, email)
with Transaction().set_context(
language=channel.language, user=0, _notify=False):
msg = Message(ModelData.get_id('ir', 'msg_chat_follower_joined'))
cls.post(resource, msg.text % {'name': email})
@classmethod
def unsubscribe_email(cls, resource, email):
cls.check_access(resource)
pool = Pool()
Follower = pool.get('ir.chat.follower')
Message = pool.get('ir.message')
ModelData = pool.get('ir.model.data')
channel = cls._get_channel(resource)
Follower.remove_email(channel, email)
with Transaction().set_context(
language=channel.language, user=0, _notify=False):
msg = Message(ModelData.get_id('ir', 'msg_chat_follower_left'))
cls.post(resource, msg.text % {'name': email})
@classmethod
def get_followers(cls, resource):
def _get_followers(cls, resource):
pool = Pool()
Follower = pool.get('ir.chat.follower')
users, emails = [], []
followers = {}
channel = cls._get_channel(resource)
if channel:
followers = Follower.search([
chan_followers = Follower.search([
('channel', '=', channel),
])
for follower in followers:
if follower.user:
users.append(follower.user.login)
elif follower.email:
emails.append(follower.email)
for f in chan_followers:
if f.user:
followers['user', f.user.login] = {
'type': 'user',
'key': f.user.login,
'name': f'{f.user.name or f.user.login}',
'avatar_url': f.user.avatar_url,
}
elif f.email:
followers['email', f.email] = {
'type': 'email',
'key': f.email,
'name': f.email,
'avatar_url': None,
}
return followers
@classmethod
def get_followers(cls, resource):
return sorted(
cls._get_followers(resource).values(), key=itemgetter('name'))
@classmethod
def _search_followers(cls, resource, text):
pool = Pool()
User = pool.get('res.user')
users = User.search([('rec_name', 'ilike', f'%{text}%')])
return {
'users': users,
'emails': emails,
}
('user', u.login): {
'type': 'user',
'key': u.login,
'name': f'{u.name or u.login}',
'avatar_url': u.avatar_url,
}
for u in users}
@classmethod
def search_followers(cls, resource, text, limit=FOLLOWER_SEARCH_LIMIT):
potential_followers = cls._search_followers(resource, text)
current_followers = cls.get_followers(resource)
for follower in current_followers:
potential_followers.pop((follower['type'], follower['key']), None)
return list(potential_followers.values())[:limit]
@classmethod
def post(cls, resource, content, audience='internal'):
@@ -144,26 +263,115 @@ class Channel(ModelSQL):
Message = pool.get('ir.chat.message')
User = pool.get('res.user')
transaction = Transaction()
user = User(transaction.user)
ctx_user = User(transaction.context.get('user', user))
ctx_user = User(transaction.context.get('user', transaction.user))
channel = cls._get_channel(resource)
message = Message(
channel=channel,
user=user,
user=ctx_user,
content=content,
audience=audience)
message.save()
cls.dispatch_message(message, lambda u: u == ctx_user)
return message
@classmethod
def _email_channel(cls, email):
return None
@classmethod
def _email_content(cls, body):
return body
@classmethod
def post_from_email(cls, email):
pool = Pool()
Message = pool.get('ir.chat.message')
if 'From' in email:
from_ = getaddresses([email.get('From')])[0][1]
else:
from_ = None
channel = cls._email_channel(email)
if channel is None:
return
if email.get_content_maintype() != 'multipart':
content = email.get_payload()
for part in email.walk():
if part.get_content_type() == 'text/plain':
content = part.get_payload()
break
message = Message(
channel=channel,
email=from_,
audience='public',
content=cls._email_content(content))
message.save()
cls.dispatch_message(message, lambda u: u == from_)
return message
@classmethod
def _email_from(cls, message):
return config.get('email', 'from')
@classmethod
def _email_reply_to(cls, message):
return None
@classmethod
def _email_body(cls, message):
return message.content
@classmethod
def dispatch_message(cls, message, is_sender):
pool = Pool()
Message = pool.get('ir.message')
ModelData = pool.get('ir.model.data')
Bus.publish(
f'chat:{str(resource)}', {
f'chat:{str(message.channel.resource)}', {
'type': 'message',
'message': message.as_dict(),
})
for follower in channel.followers:
if follower.user != ctx_user:
follower.notify(message)
return message
if not Transaction().context.get('_notify', True):
return
to_email = []
for follower in message.channel.followers:
if follower.user is not None and not is_sender(follower.user):
follower.notify(message)
if (message.audience != 'internal'
and follower.email is not None
and not is_sender(follower.email)):
to_email.append(follower.email)
if to_email:
with Transaction().set_context(language=message.channel.language):
subject_msg = Message(ModelData.get_id('ir', 'msg_subject'))
subject = subject_msg.text
from_ = cls._email_from(message)
msg = EmailMessage()
set_from_header(msg, from_, from_)
if (reply_to := cls._email_reply_to(message)):
msg['Reply-To'] = reply_to
msg['Bcc'] = to_email
msg['Auto-Submitted'] = 'auto-generated'
msg['Message-ID'] = message.reference = make_msgid(domain=host())
msg['Subject'] = subject % {
'author': message.author,
'resource': message.channel.resource.rec_name,
}
msg.set_content(cls._email_body(message))
send_message_transactional(msg)
message.save()
@classmethod
def get(cls, resource, before=None, after=None):
@@ -180,13 +388,28 @@ class Channel(ModelSQL):
messages = Message.search(domain)
return [m.as_dict() for m in messages]
@classmethod
@ModelView.button
def reset_identifier(cls, channels):
for channel in channels:
channel.identifier = uuid.uuid4().hex
cls.save(channels)
class AuthorMixin:
__slots__ = ()
author = fields.Function(fields.Char("Author"), 'on_change_with_author')
user = fields.Many2One('res.user', "User", ondelete='CASCADE')
email = fields.Char("Email")
user = fields.Many2One(
'res.user', "User", ondelete='CASCADE',
states={
'invisible': Bool(Eval('email')),
})
email = fields.Char(
"Email",
states={
'invisible': Bool(Eval('user')),
})
@classmethod
def __setup__(cls):
@@ -234,7 +457,7 @@ class AuthorMixin:
return self.user.avatar_url
class Follower(AuthorMixin, ModelSQL):
class Follower(AuthorMixin, ModelView, ModelSQL):
"Chat Follower"
__name__ = 'ir.chat.follower'
@@ -253,6 +476,7 @@ class Follower(AuthorMixin, ModelSQL):
Unique(t, t.channel, t.email),
'ir.msg_chat_follower_channel_email_unique'),
]
cls.__access__.add('channel')
@classmethod
def add_user(cls, channel, user):
@@ -312,6 +536,7 @@ class Message(AuthorMixin, ModelSQL):
('internal', "Internal"),
('public', "Public"),
], "Audience", required=True)
reference = fields.Char("Reference", readonly=True)
@classmethod
def default_audience(cls):
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data>
<record model="ir.ui.view" id="chat_channel_view_list">
<field name="model">ir.chat.channel</field>
<field name="type">tree</field>
<field name="name">chat_channel_list</field>
</record>
<record model="ir.ui.view" id="chat_channel_view_form">
<field name="model">ir.chat.channel</field>
<field name="type">form</field>
<field name="name">chat_channel_form</field>
</record>
<record model="ir.action.act_window" id="act_chat_channel">
<field name="name">Channels</field>
<field name="res_model">ir.chat.channel</field>
</record>
<record model="ir.action.act_window.view" id="act_chat_channel_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="chat_channel_view_list"/>
<field name="act_window" ref="act_chat_channel"/>
</record>
<record model="ir.action.act_window.view" id="act_chat_channel_view2">
<field name="sequence" eval="20"/>
<field name="view" ref="chat_channel_view_form"/>
<field name="act_window" ref="act_chat_channel"/>
</record>
<menuitem parent="ir.menu_models" action="act_chat_channel" id="menu_chat_channel_form"/>
<record model="ir.model.button" id="chat_channel_reset_identifier_button">
<field name="name">reset_identifier</field>
<field name="string">Reset Identifier</field>
<field name="model">ir.chat.channel</field>
<field name="confirm">This action will make previous entry point unusable. Do you want to continue?</field>
</record>
<record model="ir.model.button-res.group" id="chat_channel_reset_identifier_button_group_admin">
<field name="button" ref="chat_channel_reset_identifier_button"/>
<field name="group" ref="res.group_admin"/>
</record>
<record model="ir.model.access" id="access_chat_channel">
<field name="model">ir.chat.channel</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_chat_channel_admin">
<field name="model">ir.chat.channel</field>
<field name="group" ref="res.group_admin"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.ui.view" id="chat_follower_view_list">
<field name="model">ir.chat.follower</field>
<field name="type">tree</field>
<field name="name">chat_follower_list</field>
</record>
<record model="ir.ui.view" id="chat_follower_view_form">
<field name="model">ir.chat.follower</field>
<field name="type">form</field>
<field name="name">chat_follower_form</field>
</record>
</data>
</tryton>
+12
View File
@@ -478,6 +478,18 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.message" id="msg_chat_channel_resource_unique">
<field name="text">Only one channel per resource is allowed.</field>
</record>
<record model="ir.message" id="msg_chat_channel_identifier_unique">
<field name="text">Identifier must be unique per channel.</field>
</record>
<record model="ir.message" id="msg_chat_follower_joined">
<field name="text">"%(name)s" joined</field>
</record>
<record model="ir.message" id="msg_chat_follower_left">
<field name="text">"%(name)s" left</field>
</record>
<record model="ir.message" id="msg_subject">
<field name="text">%(resource)s: %(author)s left a message</field>
</record>
<record model="ir.message" id="msg_chat_user_or_email">
<field name="text">Only user or email can be filled.</field>
</record>
+1
View File
@@ -22,6 +22,7 @@ xml:
queue.xml
email.xml
error.xml
chat.xml
filestore.xml
[register]
@@ -0,0 +1,13 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form col="2">
<label name="resource"/>
<field name="resource"/>
<label name="identifier"/>
<group col="2" colspan="1" id="identifier">
<field name="identifier"/>
<button name="reset_identifier"/>
</group>
<field name="followers" colspan="2"/>
</form>
@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="resource" expand="2"/>
<field name="identifier" optional="1" expand="1"/>
</tree>
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form col="2">
<label name="user"/>
<field name="user"/>
<label name="email"/>
<field name="email"/>
</form>
@@ -0,0 +1,6 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="author"/>
</tree>
+65 -1
View File
@@ -1,6 +1,10 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from email.message import EmailMessage
from email.utils import getaddresses
from unittest.mock import patch
from trytond.pool import Pool
from trytond.transaction import Transaction
@@ -45,7 +49,10 @@ class NotificationTestCase(TestCase):
room.chat_post('tests.msg_chat', foo="Bar", audience='public')
self.run_tasks()
message, = Message.search([('channel', '=', channel)])
message, = Message.search([
('channel', '=', channel),
('audience', '=', 'public'),
])
self.assertEqual(message.audience, 'public')
self.assertEqual(message.content, "Chat Message: Bar")
with Transaction().set_user(alice.id):
@@ -61,3 +68,60 @@ class NotificationTestCase(TestCase):
'action': None,
'unread': True,
})
@with_transaction()
def test_chat_post_email(self):
"Test posting on chat send email"
pool = Pool()
Room = pool.get('test.chat.room')
Channel = pool.get('ir.chat.channel')
room = Room()
room.save()
Channel.subscribe_email(room, "foo@example.org")
Channel.subscribe_email(room, "bar@example.com")
with patch('trytond.ir.chat.send_message_transactional') as p:
room.chat_post('tests.msg_chat', foo="Bar", audience='public')
self.run_tasks()
(msg,), _ = p.call_args
self.assertEqual(
list(a for n, a in getaddresses([msg['Bcc']])),
['foo@example.org', 'bar@example.com'])
self.assertEqual(msg.get_content().strip(), "Chat Message: Bar")
@with_transaction()
def test_chat_post_from_email(self):
"Test posting on chat from an email"
pool = Pool()
User = pool.get('res.user')
Room = pool.get('test.chat.room')
Channel = pool.get('ir.chat.channel')
Message = pool.get('ir.chat.message')
alice, = User.create([{
'name': "Alice",
'login': 'alice',
}])
room = Room()
room.save()
Channel.subscribe(room, alice.login)
channel = Channel._get_channel(room)
msg = EmailMessage()
msg['From'] = 'foo@example.org'
msg['Subject'] = 'New message!'
msg.set_content("Test Content")
with patch.object(Channel, '_email_channel') as find_channel:
find_channel.return_value = channel
Channel.post_from_email(msg)
message, = Message.search([
('channel', '=', channel),
('audience', '=', 'public'),
])
self.assertEqual(message.content, "Test Content\n")