diff --git a/cypress/fixtures/doctype_with_tab_break.js b/cypress/fixtures/doctype_with_tab_break.js index 74e5e6abba..bc346e8fb8 100644 --- a/cypress/fixtures/doctype_with_tab_break.js +++ b/cypress/fixtures/doctype_with_tab_break.js @@ -30,6 +30,11 @@ export default { "link_doctype": "Contact", "link_fieldname": "user" }, + { + "group": "Profile", + "link_doctype": "Chat Profile", + "link_fieldname": "user" + }, ], modified_by: 'Administrator', module: 'Custom', diff --git a/frappe/__init__.py b/frappe/__init__.py index 4218aa113b..ad88901469 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1798,7 +1798,7 @@ def get_version(doctype, name, limit=None, head=False, raise_err=True): 'limit': limit }, as_list=1) - from frappe.utils import squashify, dictify, safe_json_loads + from frappe.chat.util import squashify, dictify, safe_json_loads versions = [] @@ -1856,7 +1856,7 @@ def mock(type, size=1, locale='en'): data = getattr(fake, type)() results.append(data) - from frappe.utils import squashify + from frappe.chat.util import squashify return squashify(results) def validate_and_sanitize_search_inputs(fn): diff --git a/frappe/chat/__init__.py b/frappe/chat/__init__.py new file mode 100644 index 0000000000..4c9b1c5db7 --- /dev/null +++ b/frappe/chat/__init__.py @@ -0,0 +1,23 @@ + +import frappe +from frappe import _ + +session = frappe.session + +def authenticate(user, raise_err = True): + if session.user == 'Guest': + if not frappe.db.exists('Chat Token', user): + if raise_err: + frappe.throw(_("Sorry, you're not authorized.")) + else: + return False + else: + return True + else: + if user != session.user: + if raise_err: + frappe.throw(_("Sorry, you're not authorized.")) + else: + return False + else: + return True \ No newline at end of file diff --git a/frappe/chat/doctype/__init__.py b/frappe/chat/doctype/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/chat/doctype/chat_message/__init__.py b/frappe/chat/doctype/chat_message/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/chat/doctype/chat_message/chat_message.js b/frappe/chat/doctype/chat_message/chat_message.js new file mode 100644 index 0000000000..edaad011db --- /dev/null +++ b/frappe/chat/doctype/chat_message/chat_message.js @@ -0,0 +1,10 @@ +// Copyright (c) 2017, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Chat Message', { + onload: function(frm) { + if(frm.doc.type == 'File') { + frm.set_df_property('content', 'read_only', 1); + } + } +}); diff --git a/frappe/chat/doctype/chat_message/chat_message.json b/frappe/chat/doctype/chat_message/chat_message.json new file mode 100644 index 0000000000..9d2d70c5e0 --- /dev/null +++ b/frappe/chat/doctype/chat_message/chat_message.json @@ -0,0 +1,91 @@ +{ + "beta": 1, + "creation": "2017-11-10 11:10:40.011099", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "room_type", + "type", + "user", + "room", + "content", + "mentions", + "urls" + ], + "fields": [ + { + "fieldname": "room_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Room Type", + "options": "Direct\nGroup\nVisitor", + "reqd": 1 + }, + { + "fieldname": "type", + "fieldtype": "Data", + "label": "Type", + "options": "Content\nFile" + }, + { + "fieldname": "user", + "fieldtype": "Link", + "hidden": 1, + "label": "User", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "room", + "fieldtype": "Link", + "label": "Room", + "options": "Chat Room", + "reqd": 1 + }, + { + "fieldname": "content", + "fieldtype": "Text", + "label": "Content", + "reqd": 1 + }, + { + "fieldname": "mentions", + "fieldtype": "Code", + "hidden": 1, + "label": "Mentions" + }, + { + "fieldname": "urls", + "fieldtype": "Data", + "hidden": 1, + "label": "URLs" + } + ], + "modified": "2020-09-18 17:26:09.703215", + "modified_by": "Administrator", + "module": "Chat", + "name": "Chat Message", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "search_fields": "content, user", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "content", + "track_changes": 1, + "track_seen": 1 +} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_message/chat_message.py b/frappe/chat/doctype/chat_message/chat_message.py new file mode 100644 index 0000000000..bc470a5e9c --- /dev/null +++ b/frappe/chat/doctype/chat_message/chat_message.py @@ -0,0 +1,215 @@ +# imports - standard imports +import json + +# imports - third-party imports +import requests +from bs4 import BeautifulSoup as Soup + +# imports - module imports +from frappe.model.document import Document +from frappe import _, _dict +import frappe + +# imports - frappe module imports +from frappe.chat import authenticate +from frappe.chat.util import ( + get_if_empty, + check_url, + dictify, + get_emojis, + safe_json_loads, + get_user_doc, + squashify +) + +session = frappe.session + +class ChatMessage(Document): + pass + +def get_message_urls(content): + soup = Soup(content, 'html.parser') + anchors = soup.find_all('a') + urls = [ ] + + for anchor in anchors: + text = anchor.text + + if check_url(text): + urls.append(text) + + return urls + +def get_message_mentions(content): + mentions = [ ] + tokens = content.split(' ') + + for token in tokens: + if token.startswith('@'): + what = token[1:] + if frappe.db.exists('User', what): + mentions.append(what) + else: + if frappe.db.exists('User', token): + mentions.append(token) + + return mentions + +def get_message_meta(content): + ''' + Assumes content to be HTML. Sanitizes the content + into a dict of metadata values. + ''' + meta = _dict( + links = [ ], + mentions = [ ] + ) + + meta.content = content + meta.urls = get_message_urls(content) + meta.mentions = get_message_mentions(content) + + return meta + +def sanitize_message_content(content): + emojis = get_emojis() + + tokens = content.split(' ') + for token in tokens: + if token.startswith(':') and token.endswith(':'): + what = token[1:-1] + + # Expensive, I know. + for emoji in emojis: + for alias in emoji.aliases: + if what == alias: + content = content.replace(token, emoji.emoji) + + return content + +def get_new_chat_message_doc(user, room, content, type = "Content", link = True): + user = get_user_doc(user) + room = frappe.get_doc('Chat Room', room) + + meta = get_message_meta(content) + mess = frappe.new_doc('Chat Message') + mess.room = room.name + mess.room_type = room.type + mess.content = sanitize_message_content(content) + mess.type = type + mess.user = user.name + + mess.mentions = json.dumps(meta.mentions) + mess.urls = ','.join(meta.urls) + mess.save(ignore_permissions = True) + + if link: + room.update(dict( + last_message = mess.name + )) + room.save(ignore_permissions = True) + + return mess + +def get_new_chat_message(user, room, content, type = "Content"): + mess = get_new_chat_message_doc(user, room, content, type) + + resp = dict( + name = mess.name, + user = mess.user, + room = mess.room, + room_type = mess.room_type, + content = json.loads(mess.content) if mess.type in ["File"] else mess.content, + urls = mess.urls, + mentions = json.loads(mess.mentions), + creation = mess.creation, + seen = json.loads(mess._seen) if mess._seen else [ ], + ) + + return resp + +@frappe.whitelist(allow_guest = True) +def send(user, room, content, type = "Content"): + mess = get_new_chat_message(user, room, content, type) + + frappe.publish_realtime('frappe.chat.message:create', mess, room = room, + after_commit = True) + +@frappe.whitelist(allow_guest = True) +def seen(message, user = None): + authenticate(user) + + has_message = frappe.db.exists('Chat Message', message) + + if has_message: + mess = frappe.get_doc('Chat Message', message) + mess.add_seen(user) + mess.load_from_db() + room = mess.room + resp = dict(message = message, data = dict(seen = json.loads(mess._seen) if mess._seen else [])) + + frappe.publish_realtime('frappe.chat.message:update', resp, room = room, after_commit = True) + +def history(room, fields = None, limit = 10, start = None, end = None): + room = frappe.get_doc('Chat Room', room) + mess = frappe.get_all('Chat Message', + filters = [ + ('Chat Message', 'room', '=', room.name), + ('Chat Message', 'room_type', '=', room.type) + ], + fields = fields if fields else [ + 'name', 'room_type', 'room', 'content', 'type', 'user', 'mentions', 'urls', 'creation', '_seen' + ], + order_by = 'creation' + ) + + if not fields or 'seen' in fields: + for m in mess: + m['seen'] = json.loads(m._seen) if m._seen else [ ] + del m['_seen'] + if not fields or 'content' in fields: + for m in mess: + m['content'] = json.loads(m.content) if m.type in ["File"] else m.content + + frappe.enqueue('frappe.chat.doctype.chat_message.chat_message.mark_messages_as_seen', + message_names=[m.name for m in mess], user=frappe.session.user) + + return mess + +def mark_messages_as_seen(message_names, user): + ''' + Marks chat messages as seen, updates the _seen for each message + (should be run in background process) + ''' + for name in message_names: + seen = frappe.db.get_value('Chat Message', name, '_seen') or '[]' + seen = json.loads(seen) + seen.append(user) + seen = json.dumps(seen) + frappe.db.set_value('Chat Message', name, '_seen', seen, update_modified=False) + + frappe.db.commit() + + +@frappe.whitelist() +def get(name, rooms = None, fields = None): + rooms, fields = safe_json_loads(rooms, fields) + + has_message = frappe.db.exists('Chat Message', name) + + if has_message: + dmess = frappe.get_doc('Chat Message', name) + data = dict( + name = dmess.name, + user = dmess.user, + room = dmess.room, + room_type = dmess.room_type, + content = json.loads(dmess.content) if dmess.type in ["File"] else dmess.content, + type = dmess.type, + urls = dmess.urls, + mentions = dmess.mentions, + creation = dmess.creation, + seen = get_if_empty(dmess._seen, [ ]) + ) + + return data \ No newline at end of file diff --git a/frappe/chat/doctype/chat_message/chat_message_list.js b/frappe/chat/doctype/chat_message/chat_message_list.js new file mode 100644 index 0000000000..c5b717048b --- /dev/null +++ b/frappe/chat/doctype/chat_message/chat_message_list.js @@ -0,0 +1,8 @@ +frappe.listview_settings['Chat Message'] = { + filters: [ + ['Chat Message', 'user', '==', frappe.session.user, true] + // I need an or_filter here. + // ['Chat Room', 'owner', '==', frappe.session.user, true], + // ['Chat Room', frappe.session.user, 'in', 'users', true] + ] +}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_profile/__init__.py b/frappe/chat/doctype/chat_profile/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/chat/doctype/chat_profile/chat_profile.js b/frappe/chat/doctype/chat_profile/chat_profile.js new file mode 100644 index 0000000000..b27a98faf5 --- /dev/null +++ b/frappe/chat/doctype/chat_profile/chat_profile.js @@ -0,0 +1,10 @@ +/* eslint semi: "never" */ +frappe.ui.form.on('Chat Profile', { + refresh: function (form) { + if ( form.doc.name !== frappe.session.user ) { + form.disable_save() + form.set_read_only(true) + // There's one more that faris@frappe.io told me to add here. form.refresh_fields()? + } + } +}); diff --git a/frappe/chat/doctype/chat_profile/chat_profile.json b/frappe/chat/doctype/chat_profile/chat_profile.json new file mode 100644 index 0000000000..eb36f803fe --- /dev/null +++ b/frappe/chat/doctype/chat_profile/chat_profile.json @@ -0,0 +1,98 @@ +{ + "autoname": "field:user", + "beta": 1, + "creation": "2017-11-13 18:26:57.943027", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "status", + "chat_background", + "notifications", + "message_preview", + "notification_tones", + "conversation_tones", + "settings", + "enable_chat" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "reqd": 1 + }, + { + "default": "Online", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Online\nAway\nBusy\nOffline" + }, + { + "fieldname": "chat_background", + "fieldtype": "Attach Image", + "label": "Chat Background" + }, + { + "fieldname": "notifications", + "fieldtype": "Section Break", + "label": "Notifications" + }, + { + "default": "1", + "fieldname": "message_preview", + "fieldtype": "Check", + "label": "Message Preview" + }, + { + "default": "1", + "fieldname": "notification_tones", + "fieldtype": "Check", + "label": "Notification Tones" + }, + { + "default": "1", + "fieldname": "conversation_tones", + "fieldtype": "Check", + "label": "Conversation Tones" + }, + { + "fieldname": "settings", + "fieldtype": "Section Break", + "label": "Settings" + }, + { + "default": "1", + "fieldname": "enable_chat", + "fieldtype": "Check", + "label": "Enable Chat" + } + ], + "in_create": 1, + "modified": "2019-11-07 13:21:36.414961", + "modified_by": "Administrator", + "module": "Chat", + "name": "Chat Profile", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_profile/chat_profile.py b/frappe/chat/doctype/chat_profile/chat_profile.py new file mode 100644 index 0000000000..da10a836c4 --- /dev/null +++ b/frappe/chat/doctype/chat_profile/chat_profile.py @@ -0,0 +1,98 @@ +# imports - module imports +from frappe.model.document import Document +from frappe import _ +import frappe + +# imports - frappe module imports +from frappe.core.doctype.version.version import get_diff +from frappe.chat.doctype.chat_room import chat_room +from frappe.chat.util import ( + safe_json_loads, + filter_dict, + dictify +) + +session = frappe.session + +class ChatProfile(Document): + def on_update(self): + if not self.is_new(): + b, a = self.get_doc_before_save(), self + diff = dictify(get_diff(a, b)) + if diff: + user = session.user + + fields = [changed[0] for changed in diff.changed] + + if 'status' in fields: + rooms = chat_room.get(user, filters = ['Chat Room', 'type', '=', 'Direct']) + update = dict(user = user, data = dict(status = self.status)) + + for room in rooms: + frappe.publish_realtime('frappe.chat.profile:update', update, room = room.name, after_commit = True) + + if 'enable_chat' in fields: + update = dict(user = user, data = dict(enable_chat = bool(self.enable_chat))) + frappe.publish_realtime('frappe.chat.profile:update', update, user = user, after_commit = True) + +def authenticate(user): + if user != session.user: + frappe.throw(_("Sorry, you're not authorized.")) + +@frappe.whitelist() +def get(user, fields = None): + duser = frappe.get_doc('User', user) + + if frappe.db.exists('Chat Profile', user): + dprof = frappe.get_doc('Chat Profile', user) + + # If you're adding something here, make sure the client recieves it. + profile = dict( + # User + name = duser.name, + email = duser.email, + first_name = duser.first_name, + last_name = duser.last_name, + username = duser.username, + avatar = duser.user_image, + bio = duser.bio, + # Chat Profile + status = dprof.status, + chat_background = dprof.chat_background, + message_preview = bool(dprof.message_preview), + notification_tones = bool(dprof.notification_tones), + conversation_tones = bool(dprof.conversation_tones), + enable_chat = bool(dprof.enable_chat) + ) + profile = filter_dict(profile, fields) + + return dictify(profile) + +@frappe.whitelist() +def create(user, exists_ok = False, fields = None): + authenticate(user) + + exists_ok, fields = safe_json_loads(exists_ok, fields) + + try: + dprof = frappe.new_doc('Chat Profile') + dprof.user = user + dprof.save(ignore_permissions = True) + except frappe.DuplicateEntryError: + frappe.clear_messages() + if not exists_ok: + frappe.throw(_('Chat Profile for User {0} exists.').format(user)) + + profile = get(user, fields = fields) + + return profile + +@frappe.whitelist() +def update(user, data): + authenticate(user) + + data = safe_json_loads(data) + + dprof = frappe.get_doc('Chat Profile', user) + dprof.update(data) + dprof.save(ignore_permissions = True) \ No newline at end of file diff --git a/frappe/chat/doctype/chat_profile/chat_profile_list.js b/frappe/chat/doctype/chat_profile/chat_profile_list.js new file mode 100644 index 0000000000..4d97b75e65 --- /dev/null +++ b/frappe/chat/doctype/chat_profile/chat_profile_list.js @@ -0,0 +1,11 @@ +frappe.listview_settings['Chat Profile'] = +{ + get_indicator: function (doc) + { + const status = frappe.utils.squash(frappe.chat.profile.STATUSES.filter( + s => s.name === doc.status + )); + + return [__(status.name), status.color, `status,=,${status.name}`] + } +}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room/__init__.py b/frappe/chat/doctype/chat_room/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/chat/doctype/chat_room/chat_room.js b/frappe/chat/doctype/chat_room/chat_room.js new file mode 100644 index 0000000000..00b9c8d8f7 --- /dev/null +++ b/frappe/chat/doctype/chat_room/chat_room.js @@ -0,0 +1,8 @@ +// Copyright (c) 2017, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Chat Room', { + refresh: function (form) { + + } +}); diff --git a/frappe/chat/doctype/chat_room/chat_room.json b/frappe/chat/doctype/chat_room/chat_room.json new file mode 100644 index 0000000000..1417306c45 --- /dev/null +++ b/frappe/chat/doctype/chat_room/chat_room.json @@ -0,0 +1,100 @@ +{ + "autoname": "CR.#####", + "beta": 1, + "creation": "2017-11-08 15:27:21.156667", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "type", + "room_name", + "avatar", + "last_message", + "message_count", + "owner", + "user_list", + "users" + ], + "fields": [ + { + "default": "Direct", + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Direct\nGroup\nVisitor", + "reqd": 1, + "set_only_once": 1 + }, + { + "depends_on": "eval:doc.type==\"Group\"", + "fieldname": "room_name", + "fieldtype": "Data", + "label": "Name" + }, + { + "depends_on": "eval:doc.type==\"Group\"", + "fieldname": "avatar", + "fieldtype": "Attach Image", + "hidden": 1, + "label": "Avatar" + }, + { + "fieldname": "last_message", + "fieldtype": "Data", + "hidden": 1, + "label": "Last Message" + }, + { + "fieldname": "message_count", + "fieldtype": "Int", + "hidden": 1, + "label": "Message Count" + }, + { + "fieldname": "owner", + "fieldtype": "Data", + "hidden": 1, + "label": "Owner", + "read_only": 1 + }, + { + "fieldname": "user_list", + "fieldtype": "Section Break", + "label": "Users" + }, + { + "fieldname": "users", + "fieldtype": "Table", + "label": "Users", + "options": "Chat Room User" + } + ], + "image_field": "avatar", + "modified": "2019-11-07 13:20:24.625329", + "modified_by": "Administrator", + "module": "Chat", + "name": "Chat Room", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "set_user_permissions": 1, + "share": 1, + "write": 1 + } + ], + "search_fields": "room_name", + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "room_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room/chat_room.py b/frappe/chat/doctype/chat_room/chat_room.py new file mode 100644 index 0000000000..bdbee44d7a --- /dev/null +++ b/frappe/chat/doctype/chat_room/chat_room.py @@ -0,0 +1,227 @@ +# imports - module imports +from frappe.model.document import Document +from frappe import _ +import frappe + +# imports - frappe module imports +from frappe.chat import authenticate +from frappe.core.doctype.version.version import get_diff +from frappe.chat.doctype.chat_message import chat_message +from frappe.chat.util import ( + safe_json_loads, + dictify, + listify, + squashify, + get_if_empty +) + +session = frappe.session + + +def is_direct(owner, other, bidirectional=False): + def get_room(owner, other): + room = frappe.get_all('Chat Room', filters=[ + ['Chat Room', 'type', 'in', ('Direct', 'Visitor')], + ['Chat Room', 'owner', '=', owner], + ['Chat Room User', 'user', '=', other] + ], distinct=True) + + return room + + exists = len(get_room(owner, other)) == 1 + if bidirectional: + exists = exists or len(get_room(other, owner)) == 1 + + return exists + + +def get_chat_room_user_set(users, filter_=None): + seen, uset = set(), list() + + for u in users: + if filter_(u) and u.user not in seen: + uset.append(u) + seen.add(u.user) + + return uset + + +class ChatRoom(Document): + def validate(self): + if self.is_new(): + users = get_chat_room_user_set(self.users, filter_=lambda u: u.user != session.user) + self.update(dict( + users=users + )) + + if self.type == "Direct": + if len(self.users) != 1: + frappe.throw(_('{0} room must have atmost one user.').format(self.type)) + + other = squashify(self.users) + + if self.is_new(): + if is_direct(self.owner, other.user, bidirectional=True): + frappe.throw(_('Direct room with {0} already exists.').format(other.user)) + + if self.type == "Group" and not self.room_name: + frappe.throw(_('Group name cannot be empty.')) + + def on_update(self): + if not self.is_new(): + before = self.get_doc_before_save() + if not before: return + + after = self + diff = dictify(get_diff(before, after)) + if diff: + update = {} + for changed in diff.changed: + field, old, new = changed + + if field == 'last_message': + new = chat_message.get(new) + + update.update({field: new}) + + if diff.added or diff.removed: + update.update(dict(users=[u.user for u in self.users])) + + update = dict(room=self.name, data=update) + + frappe.publish_realtime('frappe.chat.room:update', update, room=self.name, + after_commit=True) + + +@frappe.whitelist(allow_guest=True) +def get(user=None, token=None, rooms=None, fields=None, filters=None): + # There is this horrible bug out here. + # Looks like if frappe.call sends optional arguments (not in right order), + # the argument turns to an empty string. + # I'm not even going to think searching for it. + # Hence, the hack was get_if_empty (previous assign_if_none) + # - Achilles Rasquinha achilles@frappe.io + data = user or token + authenticate(data) + + rooms, fields, filters = safe_json_loads(rooms, fields, filters) + + rooms = listify(get_if_empty(rooms, [])) + fields = listify(get_if_empty(fields, [])) + + const = [] # constraints + if rooms: + const.append(['Chat Room', 'name', 'in', rooms]) + if filters: + if isinstance(filters[0], list): + const = const + filters + else: + const.append(filters) + + default = ['name', 'type', 'room_name', 'creation', 'owner', 'avatar'] + handle = ['users', 'last_message'] + + param = [f for f in fields if f not in handle] + + rooms = frappe.get_all('Chat Room', + or_filters=[ + ['Chat Room', 'owner', '=', frappe.session.user], + ['Chat Room User', 'user', '=', frappe.session.user] + ], + filters=const, + fields=param + ['name'] if param else default, + distinct=True + ) + + if not fields or 'users' in fields: + for i, r in enumerate(rooms): + droom = frappe.get_doc('Chat Room', r.name) + rooms[i]['users'] = [] + + for duser in droom.users: + rooms[i]['users'].append(duser.user) + + if not fields or 'last_message' in fields: + for i, r in enumerate(rooms): + droom = frappe.get_doc('Chat Room', r.name) + if droom.last_message: + rooms[i]['last_message'] = chat_message.get(droom.last_message) + else: + rooms[i]['last_message'] = None + + rooms = squashify(dictify(rooms)) + + return rooms + + +@frappe.whitelist(allow_guest=True) +def create(kind, token, users=None, name=None): + authenticate(token) + + users = safe_json_loads(users) + create = True + + if kind == 'Visitor': + room = squashify(frappe.db.sql(""" + SELECT name + FROM `tabChat Room` + WHERE owner=%s + """, (frappe.session.user), as_dict=True)) + + if room: + room = frappe.get_doc('Chat Room', room.name) + create = False + + if create: + room = frappe.new_doc('Chat Room') + room.type = kind + room.owner = frappe.session.user + room.room_name = name + + dusers = [] + + if kind != 'Visitor': + if users: + users = listify(users) + for user in users: + duser = frappe.new_doc('Chat Room User') + duser.user = user + dusers.append(duser) + + room.users = dusers + else: + dsettings = frappe.get_single('Website Settings') + room.room_name = dsettings.chat_room_name + + users = [user for user in room.users] if hasattr(room, 'users') else [] + + for user in dsettings.chat_operators: + if user.user not in users: + # appending user to room.users will remove the user from chat_operators + # this is undesirable, create a new Chat Room User instead + chat_room_user = {"doctype": "Chat Room User", "user": user.user} + room.append('users', chat_room_user) + + room.save(ignore_permissions=True) + + room = get(token=token, rooms=room.name) + if room: + users = [room.owner] + [u for u in room.users] + + for user in users: + frappe.publish_realtime('frappe.chat.room:create', room, user=user, after_commit=True) + + return room + + +@frappe.whitelist(allow_guest=True) +def history(room, user, fields=None, limit=10, start=None, end=None): + if frappe.get_doc('Chat Room', room).type != 'Visitor': + authenticate(user) + + fields = safe_json_loads(fields) + + mess = chat_message.history(room, limit=limit, start=start, end=end) + mess = squashify(mess) + + return dictify(mess) diff --git a/frappe/chat/doctype/chat_room/chat_room_list.js b/frappe/chat/doctype/chat_room/chat_room_list.js new file mode 100644 index 0000000000..70c708c7bd --- /dev/null +++ b/frappe/chat/doctype/chat_room/chat_room_list.js @@ -0,0 +1,6 @@ +frappe.listview_settings['Chat Room'] = { + filters: [ + ['Chat Room', 'owner', '=', frappe.session.user, true], + ['Chat Room User', 'user', '=', frappe.session.user, true] + ] +}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room_user/__init__.py b/frappe/chat/doctype/chat_room_user/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.json b/frappe/chat/doctype/chat_room_user/chat_room_user.json new file mode 100644 index 0000000000..f7bdf6706b --- /dev/null +++ b/frappe/chat/doctype/chat_room_user/chat_room_user.json @@ -0,0 +1,40 @@ +{ + "beta": 1, + "creation": "2017-11-08 15:24:21.029314", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "is_admin" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "is_admin", + "fieldtype": "Check", + "label": "Admin" + } + ], + "in_create": 1, + "istable": 1, + "modified": "2019-11-07 13:21:05.297337", + "modified_by": "Administrator", + "module": "Chat", + "name": "Chat Room User", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "read_only": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.py b/frappe/chat/doctype/chat_room_user/chat_room_user.py new file mode 100644 index 0000000000..f6dbdc7659 --- /dev/null +++ b/frappe/chat/doctype/chat_room_user/chat_room_user.py @@ -0,0 +1,8 @@ +# imports - module imports +from frappe.model.document import Document +import frappe + +session = frappe.session + +class ChatRoomUser(Document): + pass \ No newline at end of file diff --git a/frappe/chat/doctype/chat_token/__init__.py b/frappe/chat/doctype/chat_token/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/chat/doctype/chat_token/chat_token.js b/frappe/chat/doctype/chat_token/chat_token.js new file mode 100644 index 0000000000..78f03026ec --- /dev/null +++ b/frappe/chat/doctype/chat_token/chat_token.js @@ -0,0 +1,8 @@ +// Copyright (c) 2018, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Chat Token', { + refresh: function(frm) { + + } +}); diff --git a/frappe/chat/doctype/chat_token/chat_token.json b/frappe/chat/doctype/chat_token/chat_token.json new file mode 100644 index 0000000000..b73505ac2c --- /dev/null +++ b/frappe/chat/doctype/chat_token/chat_token.json @@ -0,0 +1,57 @@ +{ + "autoname": "field:token", + "beta": 1, + "creation": "2018-03-26 18:20:13.825652", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "token", + "ip_address", + "country" + ], + "fields": [ + { + "fieldname": "token", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Token", + "reqd": 1 + }, + { + "fieldname": "ip_address", + "fieldtype": "Data", + "label": "IP Address" + }, + { + "fieldname": "country", + "fieldtype": "Data", + "label": "Country" + } + ], + "in_create": 1, + "modified": "2019-11-07 13:21:24.514558", + "modified_by": "Administrator", + "module": "Chat", + "name": "Chat Token", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "read_only": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_token/chat_token.py b/frappe/chat/doctype/chat_token/chat_token.py new file mode 100644 index 0000000000..0be51b6081 --- /dev/null +++ b/frappe/chat/doctype/chat_token/chat_token.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies and contributors +# License: MIT. See LICENSE + +import frappe +from frappe.model.document import Document + +class ChatToken(Document): + pass diff --git a/frappe/chat/util/__init__.py b/frappe/chat/util/__init__.py new file mode 100644 index 0000000000..383df581cd --- /dev/null +++ b/frappe/chat/util/__init__.py @@ -0,0 +1,13 @@ +# imports - module imports +from frappe.chat.util.util import ( + get_user_doc, + squashify, + safe_json_loads, + filter_dict, + get_if_empty, + listify, + dictify, + check_url, + create_test_user, + get_emojis +) \ No newline at end of file diff --git a/frappe/chat/util/test_util.py b/frappe/chat/util/test_util.py new file mode 100644 index 0000000000..e2d05a4024 --- /dev/null +++ b/frappe/chat/util/test_util.py @@ -0,0 +1,35 @@ +# imports - standard imports +import unittest + +# imports - module imports +from frappe.chat.util import ( + get_user_doc, + safe_json_loads +) +import frappe + +class TestChatUtil(unittest.TestCase): + def test_safe_json_loads(self): + number = safe_json_loads("1") + self.assertEqual(type(number), int) + + number = safe_json_loads("1.0") + self.assertEqual(type(number), float) + + string = safe_json_loads("foobar") + self.assertEqual(type(string), str) + + array = safe_json_loads('[{ "foo": "bar" }]') + self.assertEqual(type(array), list) + + objekt = safe_json_loads('{ "foo": "bar" }') + self.assertEqual(type(objekt), dict) + + true, null = safe_json_loads("true", "null") + self.assertEqual(true, True) + self.assertEqual(null, None) + + def test_get_user_doc(self): + # Needs more test cases. + user = get_user_doc() + self.assertEqual(user.name, frappe.session.user) \ No newline at end of file diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py new file mode 100644 index 0000000000..b7e7991c2b --- /dev/null +++ b/frappe/chat/util/util.py @@ -0,0 +1,108 @@ +# imports - standard imports +import json +from collections.abc import MutableMapping, MutableSequence, Sequence + +# imports - third-party imports +import requests +from urllib.parse import urlparse + +# imports - module imports +import frappe +from frappe.exceptions import DuplicateEntryError +from frappe.model.document import Document + +session = frappe.session + + +def get_user_doc(user = None): + if isinstance(user, Document): + return user + + user = user or session.user + user = frappe.get_doc('User', user) + + return user + +def squashify(what): + if isinstance(what, Sequence) and len(what) == 1: + return what[0] + + return what + +def safe_json_loads(*args): + results = [] + + for arg in args: + try: + arg = json.loads(arg) + except Exception: + pass + + results.append(arg) + + return squashify(results) + +def filter_dict(what, keys, ignore = False): + copy = dict() + + if keys: + for k in keys: + if k not in what and not ignore: + raise KeyError('{key} not in dict.'.format(key = k)) + else: + copy.update({ + k: what[k] + }) + else: + copy = what.copy() + + return copy + +def get_if_empty(a, b): + if not a: + a = b + return a + +def listify(arg): + if not isinstance(arg, list): + arg = [arg] + return arg + +def dictify(arg): + if isinstance(arg, MutableSequence): + for i, a in enumerate(arg): + arg[i] = dictify(a) + elif isinstance(arg, MutableMapping): + arg = frappe._dict(arg) + + return arg + +def check_url(what, raise_err = False): + if not urlparse(what).scheme: + if raise_err: + raise ValueError('{what} not a valid URL.') + else: + return False + + return True + +def create_test_user(module): + try: + test_user = frappe.new_doc('User') + test_user.first_name = '{module}'.format(module = module) + test_user.email = 'testuser.{module}@example.com'.format(module = module) + test_user.save() + except DuplicateEntryError: + frappe.log('Test User Chat Profile exists.') + +def get_emojis(): + redis = frappe.cache() + emojis = redis.hget('frappe_emojis', 'emojis') + + if not emojis: + resp = requests.get('http://git.io/frappe-emoji') + if resp.ok: + emojis = resp.json() + redis.hset('frappe_emojis', 'emojis', emojis) + + return dictify(emojis) diff --git a/frappe/chat/website/__init__.py b/frappe/chat/website/__init__.py new file mode 100644 index 0000000000..12affd2782 --- /dev/null +++ b/frappe/chat/website/__init__.py @@ -0,0 +1,42 @@ + +import frappe +from frappe.chat.util import filter_dict, safe_json_loads + +from frappe.sessions import get_geo_ip_country + +@frappe.whitelist(allow_guest = True) +def settings(fields = None): + fields = safe_json_loads(fields) + + dsettings = frappe.get_single('Website Settings') + response = dict( + socketio = dict( + port = frappe.conf.socketio_port + ), + enable = bool(dsettings.chat_enable), + enable_from = dsettings.chat_enable_from, + enable_to = dsettings.chat_enable_to, + room_name = dsettings.chat_room_name, + welcome_message = dsettings.chat_welcome_message, + operators = [ + duser.user for duser in dsettings.chat_operators + ] + ) + + if fields: + response = filter_dict(response, fields) + + return response + +@frappe.whitelist(allow_guest = True) +def token(): + dtoken = frappe.new_doc('Chat Token') + + dtoken.token = frappe.generate_hash() + dtoken.ip_address = frappe.local.request_ip + country = get_geo_ip_country(dtoken.ip_address) + if country: + dtoken.country = country['iso_code'] + dtoken.save(ignore_permissions = True) + + return dtoken.token \ No newline at end of file diff --git a/frappe/core/doctype/role/role.json b/frappe/core/doctype/role/role.json index ba82e023a9..0135cbf9e8 100644 --- a/frappe/core/doctype/role/role.json +++ b/frappe/core/doctype/role/role.json @@ -17,6 +17,7 @@ "navigation_settings_section", "search_bar", "notifications", + "chat", "list_settings_section", "list_sidebar", "bulk_actions", @@ -84,6 +85,12 @@ "fieldtype": "Check", "label": "Search Bar" }, + { + "default": "1", + "fieldname": "chat", + "fieldtype": "Check", + "label": "Chat" + }, { "fieldname": "list_settings_section", "fieldtype": "Section Break", @@ -148,11 +155,10 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-10-08 14:06:55.729364", + "modified": "2021-01-27 10:35:37.638350", "modified_by": "Administrator", "module": "Core", "name": "Role", - "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index 98d2d72fc2..f4fa855ea1 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -5,7 +5,7 @@ import frappe from frappe.model.document import Document -desk_properties = ("search_bar", "notifications", "list_sidebar", +desk_properties = ("search_bar", "notifications", "chat", "list_sidebar", "bulk_actions", "view_switcher", "form_sidebar", "timeline", "dashboard") class Role(Document): @@ -82,4 +82,4 @@ def role_query(doctype, txt, searchfield, start, page_len, filters): report_filters.extend(filters) return frappe.get_all('Role', limit_start=start, limit_page_length=page_len, - filters=report_filters, as_list=1) + filters=report_filters, as_list=1) \ No newline at end of file diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index bc517f9569..4b53983702 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -65,7 +65,9 @@ "attach_view_link", "prepared_report_section", "enable_prepared_report_auto_deletion", - "prepared_report_expiry_period" + "prepared_report_expiry_period", + "chat", + "enable_chat" ], "fields": [ { @@ -379,6 +381,18 @@ "fieldtype": "Check", "label": "Hide footer in auto email reports" }, + { + "collapsible": 1, + "fieldname": "chat", + "fieldtype": "Section Break", + "label": "Chat" + }, + { + "default": "1", + "fieldname": "enable_chat", + "fieldtype": "Check", + "label": "Enable Chat" + }, { "fieldname": "column_break_21", "fieldtype": "Column Break" @@ -460,7 +474,7 @@ "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2021-10-08 14:04:11.406725", + "modified": "2021-03-30 11:47:47.330437", "modified_by": "Administrator", "module": "Core", "name": "System Settings", @@ -478,4 +492,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index a677736c16..c9e10368c5 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -617,6 +617,11 @@ "link_doctype": "Contact", "link_fieldname": "user" }, + { + "group": "Profile", + "link_doctype": "Chat Profile", + "link_fieldname": "user" + }, { "group": "Profile", "link_doctype": "Blogger", diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 86fd1cb4a6..b6f6f66ee4 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -421,6 +421,9 @@ class User(Document): WHERE `%s` = %s""" % (tab, field, '%s', field, '%s'), (new_name, old_name)) + if frappe.db.exists("Chat Profile", old_name): + frappe.rename_doc("Chat Profile", old_name, new_name, force=True, show_alert=False) + if frappe.db.exists("Notification Settings", old_name): frappe.rename_doc("Notification Settings", old_name, new_name, force=True, show_alert=False) @@ -1054,4 +1057,4 @@ def get_enabled_users(): enabled_users = frappe.get_all("User", filters={"enabled": "1"}, pluck="name") return enabled_users - return frappe.cache().get_value("enabled_users", _get_enabled_users) + return frappe.cache().get_value("enabled_users", _get_enabled_users) \ No newline at end of file diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index cf905c2ce2..85db846982 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -73,7 +73,7 @@ class TestUserPermission(unittest.TestCase): def test_for_applicable_on_update_from_apply_to_all(self): ''' Update User Permission from all to some applicable Doctypes''' user = create_user('test_bulk_creation_update@example.com') - param = get_params(user,'User', user.name, applicable = ["Comment", "Contact"]) + param = get_params(user,'User', user.name, applicable = ["Chat Room", "Chat Message"]) # Initially create User Permission document with apply_to_all checked is_created = add_user_permissions(get_params(user, 'User', user.name)) @@ -84,8 +84,8 @@ class TestUserPermission(unittest.TestCase): frappe.db.commit() removed_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user)) - is_created_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Comment")) - is_created_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Contact")) + is_created_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Room")) + is_created_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Message")) # Check that apply_to_all is removed self.assertIsNone(removed_apply_to_all) @@ -101,14 +101,14 @@ class TestUserPermission(unittest.TestCase): param = get_params(user, 'User', user.name) # create User permissions that with applicable - is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Comment", "Contact"])) + is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Chat Room", "Chat Message"])) self.assertEqual(is_created, 1) is_created = add_user_permissions(param) is_created_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user)) - removed_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Comment")) - removed_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Contact")) + removed_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Room")) + removed_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Message")) # To check that a User permission with apply_to_all exists self.assertIsNotNone(is_created_apply_to_all) diff --git a/frappe/hooks.py b/frappe/hooks.py index 8bca5c066c..2ae5a59066 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -76,6 +76,8 @@ before_tests = "frappe.utils.install.before_tests" email_append_to = ["Event", "ToDo", "Communication"] +get_rooms = 'frappe.chat.doctype.chat_room.chat_room.get_rooms' + calendars = ["Event"] leaderboards = "frappe.desk.leaderboard.get_leaderboards" @@ -279,6 +281,11 @@ sounds = [ {"name": "error", "src": "/assets/frappe/sounds/error.mp3", "volume": 0.1}, {"name": "alert", "src": "/assets/frappe/sounds/alert.mp3", "volume": 0.2}, # {"name": "chime", "src": "/assets/frappe/sounds/chime.mp3"}, + + # frappe.chat sounds + { "name": "chat-message", "src": "/assets/frappe/sounds/chat-message.mp3", "volume": 0.1 }, + { "name": "chat-notification", "src": "/assets/frappe/sounds/chat-notification.mp3", "volume": 0.1 } + # frappe.chat sounds ] bot_parsers = [ diff --git a/frappe/modules.txt b/frappe/modules.txt index a707ca853e..1229116a2e 100644 --- a/frappe/modules.txt +++ b/frappe/modules.txt @@ -9,6 +9,7 @@ Integrations Printing Contacts Data Migration +Chat Social Automation -Event Streaming \ No newline at end of file +Event Streaming diff --git a/frappe/public/js/chat.bundle.js b/frappe/public/js/chat.bundle.js new file mode 100644 index 0000000000..5f9a91ebb7 --- /dev/null +++ b/frappe/public/js/chat.bundle.js @@ -0,0 +1 @@ +import "./frappe/chat"; diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 50947bd9bc..338ddda601 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -99,6 +99,7 @@ import "./frappe/query_string.js"; // import "./frappe/ui/comment.js"; +import "./frappe/chat.js"; import "./frappe/utils/energy_point_utils.js"; import "./frappe/utils/dashboard_utils.js"; import "./frappe/ui/chart.js"; diff --git a/frappe/public/js/frappe/chat.js b/frappe/public/js/frappe/chat.js new file mode 100644 index 0000000000..fd440dcbbc --- /dev/null +++ b/frappe/public/js/frappe/chat.js @@ -0,0 +1,2788 @@ +// Frappe Chat +// Author - Achilles Rasquinha + +import Fuse from 'fuse.js' +import hyper from '../lib/hyper.min' + +import './socketio_client' + +import './ui/dialog' +import './ui/capture' + +import './utils/user' + +/* eslint semi: "never" */ +// Fuck semicolons - https://mislav.net/2010/05/semicolons + +// frappe extensions + +/** + * @description The base class for all Frappe Errors. + * + * @example + * try + * throw new frappe.Error("foobar") + * catch (e) + * console.log(e.name) + * // returns "FrappeError" + * + * @see https://stackoverflow.com/a/32749533 + * @todo Requires "transform-builtin-extend" for Babel 6 + */ +frappe.Error = Error +// class extends Error { +// constructor (message) { +// super (message) + +// this.name = 'FrappeError' + +// if ( typeof Error.captureStackTrace === 'function' ) +// Error.captureStackTrace(this, this.constructor) +// else +// this.stack = (new Error(message)).stack +// } +// } + +/** + * @description TypeError + */ +frappe.TypeError = TypeError +// class extends frappe.Error { +// constructor (message) { +// super (message) + +// this.name = this.constructor.name +// } +// } + +/** + * @description ValueError + */ +frappe.ValueError = Error +// class extends frappe.Error { +// constructor (message) { +// super (message) + +// this.name = this.constructor.name +// } +// } + +/** + * @description ImportError + */ +frappe.ImportError = Error +// class extends frappe.Error { +// constructor (message) { +// super (message) + +// this.name = this.constructor.name +// } +// } + +// frappe.datetime +frappe.provide('frappe.datetime') + +/** + * @description Frappe's datetime object. (Inspired by Python's datetime object). + * + * @example + * const datetime = new frappe.datetime.datetime() + */ +frappe.datetime.datetime = class { + /** + * @description Frappe's datetime Class's constructor. + */ + constructor (instance, format = null) { + if ( typeof moment === 'undefined' ) + throw new frappe.ImportError(`Moment.js not installed.`) + + this.moment = instance ? moment(instance, format) : moment() + } + + /** + * @description Returns a formatted string of the datetime object. + */ + format (format = null) { + const formatted = this.moment.format(format) + return formatted + } +} + +/** + * @description Frappe's daterange object. + * + * @example + * const range = new frappe.datetime.range(frappe.datetime.now(), frappe.datetime.now()) + * range.contains(frappe.datetime.now()) + */ +frappe.datetime.range = class { + constructor (start, end) { + if ( typeof moment === undefined ) + throw new frappe.ImportError(`Moment.js not installed.`) + + this.start = start + this.end = end + } + + contains (datetime) { + const contains = datetime.moment.isBetween(this.start.moment, this.end.moment) + return contains + } +} + +/** + * @description Returns the current datetime. + * + * @example + * const datetime = new frappe.datetime.now() + */ +frappe.datetime.now = () => new frappe.datetime.datetime() + +frappe.datetime.equal = (a, b, type) => { + a = a.moment + b = b.moment + + const equal = a.isSame(b, type) + + return equal +} + +/** + * @description Compares two frappe.datetime.datetime objects. + * + * @param {frappe.datetime.datetime} a - A frappe.datetime.datetime/moment object. + * @param {frappe.datetime.datetime} b - A frappe.datetime.datetime/moment object. + * + * @returns {number} 0 (if a and b are equal), 1 (if a is before b), -1 (if a is after b). + * + * @example + * frappe.datetime.compare(frappe.datetime.now(), frappe.datetime.now()) + * // returns 0 + * const then = frappe.datetime.now() + * + * frappe.datetime.compare(then, frappe.datetime.now()) + * // returns 1 + */ +frappe.datetime.compare = (a, b) => { + a = a.moment + b = b.moment + + if ( a.isBefore(b) ) + return 1 + else + if ( b.isBefore(a) ) + return -1 + else + return 0 +} + +// frappe.quick_edit +frappe.quick_edit = (doctype, docname, fn) => { + return new Promise(resolve => { + frappe.model.with_doctype(doctype, () => { + frappe.db.get_doc(doctype, docname).then(doc => { + const meta = frappe.get_meta(doctype) + const fields = meta.fields + const required = fields.filter(f => f.reqd || f.bold && !f.read_only) + + required.map(f => { + if(f.fieldname == 'content' && doc.type == 'File') { + f['read_only'] = 1; + } + }) + + const dialog = new frappe.ui.Dialog({ + title: __('Edit') + `${doctype} (${docname})`, + fields: required, + action: { + primary: { + label: __("Save"), + onsubmit: (values) => { + frappe.call('frappe.client.save', + { doc: { doctype: doctype, docname: docname, ...doc, ...values } }) + .then(r => { + if ( fn ) + fn(r.message) + + resolve(r.message) + }) + + dialog.hide() + } + }, + secondary: { + label: __("Discard") + } + } + }) + dialog.set_values(doc) + + const $element = $(dialog.body) + $element.append(` +
+ +
+ `) + $element.find('.qe-fp').click(() => { + dialog.hide() + frappe.set_route('Form', doctype, docname) + }) + + dialog.show() + }) + }) + }) +} + +// frappe._ +// frappe's utility namespace. +frappe.provide('frappe._') + +// String Utilities + +/** + * @description Python-inspired format extension for string objects. + * + * @param {string} string - A string with placeholders. + * @param {object} object - An object with placeholder, value pairs. + * + * @return {string} - The formatted string. + * + * @example + * frappe._.format('{foo} {bar}', { bar: 'foo', foo: 'bar' }) + * // returns "bar foo" + */ +frappe._.format = (string, object) => { + for (const key in object) + string = string.replace(`{${key}}`, object[key]) + + return string +} + +/** + * @description Fuzzy Search a given query within a dataset. + * + * @param {string} query - A query string. + * @param {array} dataset - A dataset to search within, can contain singletons or objects. + * @param {object} options - Options as per fuze.js + * + * @return {array} - The fuzzy matched index/object within the dataset. + * + * @example + * frappe._.fuzzy_search("foobar", ["foobar", "bartender"]) + * // returns [0, 1] + * + * @see http://fusejs.io + */ +frappe._.fuzzy_search = (query, dataset, options) => { + const DEFAULT = { + shouldSort: true, + threshold: 0.6, + location: 0, + distance: 100, + minMatchCharLength: 1, + maxPatternLength: 32 + } + options = { ...DEFAULT, ...options } + + const fuse = new Fuse(dataset, options) + const result = fuse.search(query) + + return result +} + +/** + * @description Pluralizes a given word. + * + * @param {string} word - The word to be pluralized. + * @param {number} count - The count. + * + * @return {string} - The pluralized string. + * + * @example + * frappe._.pluralize('member', 1) + * // returns "member" + * frappe._.pluralize('members', 0) + * // returns "members" + * + * @todo Handle more edge cases. + */ +frappe._.pluralize = (word, count = 0, suffix = 's') => `${word}${count === 1 ? '' : suffix}` + +/** + * @description Captializes a given string. + * + * @param {word} - The word to be capitalized. + * + * @return {string} - The capitalized word. + * + * @example + * frappe._.capitalize('foobar') + * // returns "Foobar" + */ +frappe._.capitalize = word => `${word.charAt(0).toUpperCase()}${word.slice(1)}` + +// Array Utilities + +/** + * @description Returns the first element of an array. + * + * @param {array} array - The array. + * + * @returns - The first element of an array, undefined elsewise. + * + * @example + * frappe._.head([1, 2, 3]) + * // returns 1 + * frappe._.head([]) + * // returns undefined + */ +frappe._.head = arr => frappe._.is_empty(arr) ? undefined : arr[0] + +/** + * @description Returns a copy of the given array (shallow). + * + * @param {array} array - The array to be copied. + * + * @returns {array} - The copied array. + * + * @example + * frappe._.copy_array(["foobar", "barfoo"]) + * // returns ["foobar", "barfoo"] + * + * @todo Add optional deep copy. + */ +frappe._.copy_array = array => { + if ( Array.isArray(array) ) + return array.slice() + else + throw frappe.TypeError(`Expected Array, recieved ${typeof array} instead.`) +} + +/** + * @description Check whether an array|string|object|jQuery is empty. + * + * @param {any} value - The value to be checked on. + * + * @returns {boolean} - Returns if the object is empty. + * + * @example + * frappe._.is_empty([]) // returns true + * frappe._.is_empty(["foo"]) // returns false + * + * frappe._.is_empty("") // returns true + * frappe._.is_empty("foo") // returns false + * + * frappe._.is_empty({ }) // returns true + * frappe._.is_empty({ foo: "bar" }) // returns false + * + * frappe._.is_empty($('.papito')) // returns false + * + * @todo Handle other cases. + */ +frappe._.is_empty = value => { + let empty = false + + if ( value === undefined || value === null ) + empty = true + else + if ( Array.isArray(value) || typeof value === 'string' || value instanceof $ ) + empty = value.length === 0 + else + if ( typeof value === 'object' ) + empty = Object.keys(value).length === 0 + + return empty +} + +/** + * @description Converts a singleton to an array, if required. + * + * @param {object} item - An object + * + * @example + * frappe._.as_array("foo") + * // returns ["foo"] + * + * frappe._.as_array(["foo"]) + * // returns ["foo"] + * + * @see https://docs.oracle.com/javase/8/docs/api/java/util/Arrays.html#asList-T...- + */ +frappe._.as_array = item => Array.isArray(item) ? item : [item] + +/** + * @description Return a singleton if array contains a single element. + * + * @param {array} list - An array to squash. + * + * @returns {array|object} - Returns an array if there's more than 1 object else the first object itself. + * + * @example + * frappe._.squash(["foo"]) + * // returns "foo" + * + * frappe._.squash(["foo", "bar"]) + * // returns ["foo", "bar"] + */ +frappe._.squash = list => Array.isArray(list) && list.length === 1 ? list[0] : list + +/** + * @description Returns true, if the current device is a mobile device. + * + * @example + * frappe._.is_mobile() + * // returns true|false + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent + */ +frappe._.is_mobile = () => { + const regex = new RegExp("Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini", "i") + const agent = navigator.userAgent + const mobile = regex.test(agent) + + return mobile +} + +/** + * @description Removes falsey values from an array. + * + * @example + * frappe._.compact([1, 2, false, NaN, '']) + * // returns [1, 2] + */ +frappe._.compact = array => array.filter(Boolean) + +// extend utils to base. +frappe.utils = { ...frappe.utils, ...frappe._ } + +// frappe extensions + +// frappe.user extensions +/** + * @description Returns the first name of a User. + * + * @param {string} user - User + * + * @returns The first name of the user. + * + * @example + * frappe.user.first_name("Rahul Malhotra") + * // returns "Rahul" + */ +frappe.provide('frappe.user') +frappe.user.first_name = user => frappe._.head(frappe.user.full_name(user).split(" ")) + +frappe.provide('frappe.ui.keycode') +frappe.ui.keycode = { RETURN: 13 } + +/** + * @description Frappe's Store Class + */ + // frappe.stores - A registry for frappe stores. +frappe.provide('frappe.stores') +frappe.stores = [ ] +frappe.Store = class +{ + /** + * @description Frappe's Store Class's constructor. + * + * @param {string} name - Name of the logger. + */ + constructor (name) { + if ( typeof name !== 'string' ) + throw new frappe.TypeError(`Expected string for name, got ${typeof name} instead.`) + this.name = name + } + + /** + * @description Get instance of frappe.Store (return registered one if declared). + * + * @param {string} name - Name of the store. + */ + static get (name) { + if ( !(name in frappe.stores) ) + frappe.stores[name] = new frappe.Store(name) + return frappe.stores[name] + } + + set (key, value) { localStorage.setItem(`${this.name}:${key}`, value) } + get (key, value) { return localStorage.getItem(`${this.name}:${key}`) } +} + +// frappe.loggers - A registry for frappe loggers. +frappe.provide('frappe.loggers') +/** + * @description Frappe's Logger Class + * + * @example + * frappe.log = frappe.Logger.get('foobar') + * frappe.log.level = frappe.Logger.DEBUG + * + * frappe.log.info('foobar') + * // prints '[timestamp] foobar: foobar' + */ +frappe.Logger = class { + /** + * @description Frappe's Logger Class's constructor. + * + * @param {string} name - Name of the logger. + */ + constructor (name, level) { + if ( typeof name !== 'string' ) + throw new frappe.TypeError(`Expected string for name, got ${typeof name} instead.`) + + this.name = name + this.level = level + + if ( !this.level ) { + if ( frappe.boot.developer_mode ) + this.level = frappe.Logger.ERROR + else + this.level = frappe.Logger.NOTSET + } + this.format = frappe.Logger.FORMAT + } + + /** + * @description Get instance of frappe.Logger (return registered one if declared). + * + * @param {string} name - Name of the logger. + */ + static get (name, level) { + if ( !(name in frappe.loggers) ) + frappe.loggers[name] = new frappe.Logger(name, level) + return frappe.loggers[name] + } + + debug (message) { this.log(message, frappe.Logger.DEBUG) } + info (message) { this.log(message, frappe.Logger.INFO) } + warn (message) { this.log(message, frappe.Logger.WARN) } + error (message) { this.log(message, frappe.Logger.ERROR) } + + log (message, level) { + const timestamp = frappe.datetime.now() + + if ( level.value <= this.level.value ) { + const format = frappe._.format(this.format, { + time: timestamp.format('HH:mm:ss'), + name: this.name + }) + console.log(`%c ${format}:`, `color: ${level.color}`, message) + } + } +} + +frappe.Logger.DEBUG = { value: 10, color: '#616161', name: 'DEBUG' } +frappe.Logger.INFO = { value: 20, color: '#2196F3', name: 'INFO' } +frappe.Logger.WARN = { value: 30, color: '#FFC107', name: 'WARN' } +frappe.Logger.ERROR = { value: 40, color: '#F44336', name: 'ERROR' } +frappe.Logger.NOTSET = { value: 0, name: 'NOTSET' } + +frappe.Logger.FORMAT = '{time} {name}' + +// frappe.chat +frappe.provide('frappe.chat') + +frappe.log = frappe.Logger.get('frappe.chat', frappe.Logger.NOTSET) + +// frappe.chat.profile +frappe.provide('frappe.chat.profile') + +/** + * @description Create a Chat Profile. + * + * @param {string|array} fields - (Optional) fields to be retrieved after creating a Chat Profile. + * @param {function} fn - (Optional) callback with the returned Chat Profile. + * + * @returns {Promise} + * + * @example + * frappe.chat.profile.create(console.log) + * + * frappe.chat.profile.create("status").then(console.log) // { status: "Online" } + */ +frappe.chat.profile.create = (fields, fn) => { + if ( typeof fields === "function" ) { + fn = fields + fields = null + } else + if ( typeof fields === "string" ) + fields = frappe._.as_array(fields) + + return new Promise(resolve => { + frappe.call("frappe.chat.doctype.chat_profile.chat_profile.create", + { user: frappe.session.user, exists_ok: true, fields: fields }, + response => { + if ( fn ) + fn(response.message) + + resolve(response.message) + }) + }) +} + +/** + * @description Updates a Chat Profile. + * + * @param {string} user - (Optional) Chat Profile User, defaults to session user. + * @param {object} update - (Required) Updates to be dispatched. + * + * @example + * frappe.chat.profile.update(frappe.session.user, { "status": "Offline" }) + */ +frappe.chat.profile.update = (user, update, fn) => { + return new Promise(resolve => { + frappe.call("frappe.chat.doctype.chat_profile.chat_profile.update", + { user: user || frappe.session.user, data: update }, + response => { + if ( fn ) + fn(response.message) + + resolve(response.message) + }) + }) +} + +// frappe.chat.profile.on +frappe.provide('frappe.chat.profile.on') + +/** + * @description Triggers on a Chat Profile update of a user (Only if there's a one-on-one conversation). + * + * @param {function} fn - (Optional) callback with the User and the Chat Profile update. + * + * @returns {Promise} + * + * @example + * frappe.chat.profile.on.update(function (user, update) + * { + * // do stuff + * }) + */ +frappe.chat.profile.on.update = function (fn) { + frappe.realtime.on("frappe.chat.profile:update", r => fn(r.user, r.data)) +} +frappe.chat.profile.STATUSES += +[ + { + name: "Online", + color: "green" + }, + { + name: "Away", + color: "yellow" + }, + { + name: "Busy", + color: "red" + }, + { + name: "Offline", + color: "gray" + } +] + +// frappe.chat.room +frappe.provide('frappe.chat.room') + +/** + * @description Creates a Chat Room. + * + * @param {string} kind - (Required) "Direct", "Group" or "Visitor". + * @param {string} owner - (Optional) Chat Room owner (defaults to current user). + * @param {string|array} users - (Required for "Direct" and "Visitor", Optional for "Group") User(s) within Chat Room. + * @param {string} name - Chat Room name. + * @param {function} fn - callback with created Chat Room. + * + * @returns {Promise} + * + * @example + * frappe.chat.room.create("Direct", frappe.session.user, "foo@bar.com", function (room) { + * // do stuff + * }) + * frappe.chat.room.create("Group", frappe.session.user, ["santa@gmail.com", "banta@gmail.com"], "Santa and Banta", function (room) { + * // do stuff + * }) + */ +frappe.chat.room.create = function (kind, owner, users, name, fn) { + if ( typeof name === "function" ) { + fn = name + name = null + } + + users = frappe._.as_array(users) + + return new Promise(resolve => { + frappe.call("frappe.chat.doctype.chat_room.chat_room.create", + { kind: kind, token: owner || frappe.session.user, users: users, name: name }, + r => { + let room = r.message + room = { ...room, creation: new frappe.datetime.datetime(room.creation) } + + if ( fn ) + fn(room) + + resolve(room) + }) + }) +} + +/** + * @description Returns Chat Room(s). + * + * @param {string|array} names - (Optional) Chat Room(s) to retrieve. + * @param {string|array} fields - (Optional) fields to be retrieved for each Chat Room. + * @param {function} fn - (Optional) callback with the returned Chat Room(s). + * + * @returns {Promise} + * + * @example + * frappe.chat.room.get(function (rooms) { + * // do stuff + * }) + * frappe.chat.room.get().then(function (rooms) { + * // do stuff + * }) + * + * frappe.chat.room.get(null, ["room_name", "avatar"], function (rooms) { + * // do stuff + * }) + * + * frappe.chat.room.get("CR00001", "room_name", function (room) { + * // do stuff + * }) + * + * frappe.chat.room.get(["CR00001", "CR00002"], ["room_name", "last_message"], function (rooms) { + * + * }) + */ +frappe.chat.room.get = function (names, fields, fn) { + if ( typeof names === "function" ) { + fn = names + names = null + fields = null + } + else + if ( typeof names === "string" ) { + names = frappe._.as_array(names) + + if ( typeof fields === "function" ) { + fn = fields + fields = null + } + else + if ( typeof fields === "string" ) + fields = frappe._.as_array(fields) + } + + return new Promise(resolve => { + frappe.call("frappe.chat.doctype.chat_room.chat_room.get", + { user: frappe.session.user, rooms: names, fields: fields }, + response => { + let rooms = response.message + if ( rooms ) { // frappe.api BOGZ! (emtpy arrays are falsified, not good design). + rooms = frappe._.as_array(rooms) + rooms = rooms.map(room => { + return { ...room, creation: new frappe.datetime.datetime(room.creation), + last_message: room.last_message ? { + ...room.last_message, + creation: new frappe.datetime.datetime(room.last_message.creation) + } : null + } + }) + rooms = frappe._.squash(rooms) + } + else + rooms = [ ] + + if ( fn ) + fn(rooms) + + resolve(rooms) + }) + }) +} + +/** + * @description Subscribe current user to said Chat Room(s). + * + * @param {string|array} rooms - Chat Room(s). + * + * @example + * frappe.chat.room.subscribe("CR00001") + */ +frappe.chat.room.subscribe = function (rooms) { + frappe.realtime.publish("frappe.chat.room:subscribe", rooms) +} + +/** + * @description Get Chat Room history. + * + * @param {string} name - Chat Room name + * + * @returns {Promise} - Chat Message(s) + * + * @example + * frappe.chat.room.history(function (messages) + * { + * // do stuff. + * }) + */ +frappe.chat.room.history = function (name, fn) { + return new Promise(resolve => { + frappe.call("frappe.chat.doctype.chat_room.chat_room.history", + { room: name, user: frappe.session.user }, + r => { + let messages = r.message ? frappe._.as_array(r.message) : [ ] // frappe.api BOGZ! (emtpy arrays are falsified, not good design). + messages = messages.map(m => { + return { ...m, + creation: new frappe.datetime.datetime(m.creation) + } + }) + + if ( fn ) + fn(messages) + + resolve(messages) + }) + }) +} + +/** + * @description Searches Rooms based on a query. + * + * @param {string} query - The query string. + * @param {array} rooms - A list of Chat Rooms. + * + * @returns {array} - A fuzzy searched list of rooms. + */ +frappe.chat.room.search = function (query, rooms) { + const dataset = rooms.map(r => { + if ( r.room_name ) + return r.room_name + else + if ( r.owner === frappe.session.user ) + return frappe.user.full_name(frappe._.squash(r.users)) + else + return frappe.user.full_name(r.owner) + }) + const results = frappe._.fuzzy_search(query, dataset) + rooms = results.map(i => rooms[i]) + + return rooms +} + +/** + * @description Sort Chat Room(s) based on Last Message Timestamp or Creation Date. + * + * @param {array} - A list of Chat Room(s) + * @param {compare} - (Optional) a comparision function. + */ +frappe.chat.room.sort = function (rooms, compare = null) { + compare = compare || function (a, b) { + if ( a.last_message && b.last_message ) + return frappe.datetime.compare(a.last_message.creation, b.last_message.creation) + else + if ( a.last_message ) + return frappe.datetime.compare(a.last_message.creation, b.creation) + else + if ( b.last_message ) + return frappe.datetime.compare(a.creation, b.last_message.creation) + else + return frappe.datetime.compare(a.creation, b.creation) + } + rooms.sort(compare) + + return rooms +} + +// frappe.chat.room.on +frappe.provide('frappe.chat.room.on') + +/** + * @description Triggers on Chat Room updated. + * + * @param {function} fn - callback with the Chat Room and Update. + */ +frappe.chat.room.on.update = function (fn) { + frappe.realtime.on("frappe.chat.room:update", r => { + if ( r.data.last_message ) + // creation to frappe.datetime.datetime (easier to manipulate). + r.data = { ...r.data, last_message: { ...r.data.last_message, creation: new frappe.datetime.datetime(r.data.last_message.creation) } } + + fn(r.room, r.data) + }) +} + +/** + * @description Triggers on Chat Room created. + * + * @param {function} fn - callback with the created Chat Room. + */ +frappe.chat.room.on.create = function (fn) { + frappe.realtime.on("frappe.chat.room:create", r => + fn({ ...r, creation: new frappe.datetime.datetime(r.creation) }) + ) +} + +/** + * @description Triggers when a User is typing in a Chat Room. + * + * @param {function} fn - callback with the typing User within the Chat Room. + */ +frappe.chat.room.on.typing = function (fn) { + frappe.realtime.on("frappe.chat.room:typing", r => fn(r.room, r.user)) +} + +// frappe.chat.message +frappe.provide('frappe.chat.message') + +frappe.chat.message.typing = function (room, user) { + frappe.realtime.publish("frappe.chat.message:typing", { user: user || frappe.session.user, room: room }) +} + +frappe.chat.message.send = function (room, message, type = "Content") { + frappe.call("frappe.chat.doctype.chat_message.chat_message.send", + { user: frappe.session.user, room: room, content: message, type: type }) +} + +frappe.chat.message.update = function (message, update, fn) { + return new Promise(resolve => { + frappe.call('frappe.chat.doctype.chat_message.chat_message.update', + { user: frappe.session.user, message: message, update: update }, + r => { + if ( fn ) + fn(response.message) + + resolve(response.message) + }) + }) +} + +frappe.chat.message.sort = (messages) => { + if ( !frappe._.is_empty(messages) ) + messages.sort((a, b) => frappe.datetime.compare(b.creation, a.creation)) + + return messages +} + +/** + * @description Add user to seen (defaults to session.user) + */ +frappe.chat.message.seen = (mess, user) => { + frappe.call('frappe.chat.doctype.chat_message.chat_message.seen', + { message: mess, user: user || frappe.session.user }) +} + +frappe.provide('frappe.chat.message.on') +frappe.chat.message.on.create = function (fn) { + frappe.realtime.on("frappe.chat.message:create", r => + fn({ ...r, creation: new frappe.datetime.datetime(r.creation) }) + ) +} + +frappe.chat.message.on.update = function (fn) { + frappe.realtime.on("frappe.chat.message:update", r => fn(r.message, r.data)) +} + +frappe.chat.pretty_datetime = function (date) { + const today = moment() + const instance = date.moment + + if ( today.isSame(instance, "d") ) + return instance.format("hh:mm A") + else + if ( today.isSame(instance, "week") ) + return instance.format("dddd") + else + return instance.format("DD/MM/YYYY") +} + +// frappe.chat.sound +frappe.provide('frappe.chat.sound') + +/** + * @description Plays a given registered sound. + * + * @param {value} - The name of the registered sound. + * + * @example + * frappe.chat.sound.play("message") + */ +frappe.chat.sound.play = function (name, volume = 0.1) { + // frappe._.play_sound(`chat-${name}`) + const $audio = $(`