/* Part of SWISH Author: Jan Wielemaker E-mail: J.Wielemaker@cs.vu.nl WWW: http://www.swi-prolog.org Copyright (C): 2017, VU University Amsterdam CWI Amsterdam All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ :- module(swish_notify, [ follow/3, % +DocID, +ProfileID, +Options notify/2 % +DocID, +Action ]). :- use_module(library(settings)). :- use_module(library(persistency)). :- use_module(library(broadcast)). :- use_module(library(lists)). :- use_module(library(readutil)). :- use_module(library(debug)). :- use_module(library(error)). :- use_module(library(apply)). :- use_module(library(http/html_write)). :- use_module(library(http/http_session)). :- use_module(library(http/http_dispatch)). :- use_module(library(http/http_parameters)). :- use_module(library(http/http_json)). :- use_module(library(redis)). :- use_module(library(user_profile)). :- use_module(email). :- use_module('../bootstrap'). :- use_module('../storage'). :- use_module('../chat'). :- use_module('../config'). /** SWISH notifications This module keeps track of which users wish to track which notifications and sending the notifications to the user. If the target user is online we will notify using an avatar. Otherwise we send an email. A user has the following options to control notifications: * Per (gitty) file - Notify update - Notify chat * By profile - Notify by E-mail: never/immediate/daily */ :- setting(database, callable, data('notify.db'), "Database holding notifications"). :- setting(queue, callable, data('notify-queue.db'), "File holding queued messages"). :- setting(daily, compound, 04:00, "Time at which to send daily messages"). :- meta_predicate try(0). /******************************* * DATABASE * *******************************/ %! redis_docid_key(+DocId, -Server, -Key) is semidet. %! redis_queue_key(Server, -Key) is semidet. % % Get the Redis server and key for specific requests. The Redis DB is % organised as follows: % % - Prefix:notify:docid:Docid % A hash mapping profile ids onto a Prolog list of flags % - Prefix:notify:queue % A list of Prolog terms holding queued pending % email notifications. redis_docid_key(DocId, Server, Key) :- swish_config(redis, Server), swish_config(redis_prefix, Prefix), atomic_list_concat([Prefix, notify, docid, DocId], :, Key). redis_queue_key(Server, Key) :- swish_config(redis, Server), swish_config(redis_prefix, Prefix), atomic_list_concat([Prefix, notify, queue], :, Key). :- persistent follower(docid:atom, profile:atom, options:list(oneof([update,chat]))). notify_open_db :- db_attached(_), !. notify_open_db :- setting(database, Spec), absolute_file_name(Spec, Path, [access(write)]), db_attach(Path, [sync(close)]). %! queue_event(+Profile, +DocID, +Action) is det. %! queue_event(+Profile, +DocID, +Action, +Status) is det. % % Queue an email notification for Profile, described by Action. We % simply append these events as Prolog terms to a file. % % @arg Status is one of `new` or retry(Count,Status), where Count is % the number of left attempts and Status is the last status. queue_event(Profile, DocID, Action) :- queue_event(Profile, DocID, Action, new). queue_event(Profile, DocID, Action, Status) :- redis_queue_key(Server, Key), !, redis(Server, rpush(Key, notify(Profile, DocID, Action, Status) as prolog)). queue_event(Profile, DocID, Action, Status) :- queue_file(Path), with_mutex(swish_notify, queue_event_sync(Path, Profile, DocID, Action, Status)), start_mail_scheduler. queue_event_sync(Path, Profile, DocID, Action, Status) :- setup_call_cleanup( open(Path, append, Out, [encoding(utf8)]), format(Out, '~q.~n', [notify(Profile, DocID, Action, Status)]), close(Out)). queue_file(Path) :- setting(queue, Spec), absolute_file_name(Spec, Path, [access(write)]). %! send_queued_mails is det. % % Send possible queued emails. send_queued_mails :- redis_queue_key(Server, Key), !, redis_send_queued_mails(Server, Key). send_queued_mails :- queue_file(Path), exists_file(Path), !, atom_concat(Path, '.sending', Tmp), with_mutex(swish_notify, rename_file(Path, Tmp)), read_file_to_terms(Tmp, Terms, [encoding(utf8)]), forall(member(Term, Terms), send_queued(Term)), delete_file(Tmp). send_queued_mails. redis_send_queued_mails(Server, Key) :- ( redis(Server, lpop(Key), Term) -> send_queued(Term), redis_send_queued_mails(Server, Key) ; true ). send_queued(notify(Profile, DocID, Action, Status)) :- profile_property(Profile, email(Email)), profile_property(Profile, email_notifications(When)), When \== never, !, ( catch(send_notification_mail(Profile, DocID, Email, Action), Error, true) -> ( var(Error) -> true ; update_status(Status, Error, NewStatus) -> queue_event(Profile, Action, NewStatus) ; true ) ; update_status(Status, failed, NewStatus) -> queue_event(Profile, DocID, Action, NewStatus) ; true ). update_status(new, Status, retry(3, Status)). update_status(retry(Count0, _), Status, retry(Count, Status)) :- Count0 > 0, Count is Count0 - 1. %! start_mail_scheduler % % Start a thread that schedules queued mail handling. :- dynamic mail_scheduler_running/0. start_mail_scheduler :- mail_scheduler_running, !. start_mail_scheduler :- catch(thread_create(mail_main, _, [ alias(mail_scheduler), detached(true), at_exit(retractall(mail_scheduler_running)) ]), error(permission_error(create, thread, mail_scheduler), _), true). %! mail_main % % Infinite loop that schedules sending queued messages. mail_main :- asserta(mail_scheduler_running), repeat, next_send_queue_time(T), get_time(Now), Sleep is T-Now, sleep(Sleep), thread_create(send_queued_mails, _, [ detached(true), alias(send_queued_mails) ]), fail. next_send_queue_time(T) :- get_time(Now), stamp_date_time(Now, date(Y,M,D0,H0,_M,_S,Off,TZ,DST), local), setting(daily, HH:MM), ( H0 @< HH -> D = D0 ; D is D0+1 ), date_time_stamp(date(Y,M,D,HH,MM,0,Off,TZ,DST), T). %! following(+DocID, ?ProfileID, ?Flags) is nondet. following(DocID, ProfileID, Flags) :- redis_docid_key(DocID, Server, Key), redis(Server, hgetall(Key), Pairs as pairs(atom, auto)), member(ProfileID-Flags, Pairs). %! follow(+DocID, +ProfileID, +Flags) is det. % % Assert that DocID is being followed by ProfileID using Flags. follow(DocID, ProfileID, Flags) :- redis_docid_key(DocID, Server, Key), !, ( Flags == [] -> redis(Server, hdel(Key, ProfileID)) ; maplist(to_atom, Flags, Options), redis(Server, hset(Key, ProfileID, Options as prolog)) ). follow(DocID, ProfileID, Flags) :- to_atom(DocID, DocIDA), to_atom(ProfileID, ProfileIDA), maplist(to_atom, Flags, Options), notify_open_db, ( follower(DocIDA, ProfileIDA, OldOptions) -> ( OldOptions == Options -> true ; retractall_follower(DocIDA, ProfileIDA, _), ( Options \== [] -> assert_follower(DocIDA, ProfileIDA, Options) ; true ) ) ; Options \== [] -> assert_follower(DocIDA, ProfileIDA, Options) ; true ). nofollow(DocID, ProfileID, Flags) :- redis_docid_key(DocID, Server, Key), !, maplist(to_atom, Flags, Options), ( redis(Server, hget(Key, ProfileID), OldOptions) -> subtract(OldOptions, Options, NewOptions), follow(DocID, ProfileID, NewOptions) ; true ). nofollow(DocID, ProfileID, Flags) :- to_atom(DocID, DocIDA), to_atom(ProfileID, ProfileIDA), maplist(to_atom, Flags, Options), ( follower(DocIDA, ProfileIDA, OldOptions) -> subtract(OldOptions, Options, NewOptions), follow(DocID, ProfileID, NewOptions) ; true ). %! notify(+DocID, +Action) is det. % % Action has been executed on DocID. Notify all interested users. % Actions that may be notified: % % - updated(Commit) % Gitty file was updated % - deleted(Commit) % Gitty file was deleted % - forked(OldCommit, Commit) % Gitty file was forked % - created(Commit) % A new gitty file was created % - chat(Message) % A chat message was sent. Message is the JSON content as a dict. % Message contains a `docid` key. notify(DocID, Action) :- to_atom(DocID, DocIDA), try(notify_in_chat(DocIDA, Action)), notify_open_db, forall(following(DocIDA, Profile, Options), notify_user(Profile, DocIDA, Action, Options)). to_atom(Text, Atom) :- atom_string(Atom, Text). %! notify_user(+Profile, +DocID, +Action, +Options) % % Notify the user belonging to Profile about Action, which is related % to document DocID. notify_user(Profile, _, Action, _Options) :- % exclude self event_generator(Action, Profile), debug(notify(self), 'Notification to self ~p', [Profile]), \+ debugging(notify_self), !. notify_user(Profile, DocID, Action, Options) :- try(notify_online(Profile, Action, Options)), try(notify_by_mail(Profile, DocID, Action, Options)). try(Goal) :- catch(Goal, Error, print_message(error, Error)), !. try(_0Goal) :- debug(notify(fail), 'Failed: ~p', [_0Goal]). /******************************* * BROADCAST API * *******************************/ :- unlisten(swish(_)), listen(swish(Event), notify_event(Event)). % request to follow this file notify_event(follow(DocID, ProfileID, Options)) :- follow(DocID, ProfileID, Options). % events on gitty files notify_event(updated(File, Commit)) :- storage_meta_data(Commit.get(previous), OldCommit), ( atom_concat('gitty:', OldCommit.name, DocID) -> notify(DocID, updated(Commit)) ; atom_concat('gitty:', File, DocID), notify(DocID, forked(OldCommit, Commit)) ). notify_event(deleted(File, Commit)) :- atom_concat('gitty:', File, DocID), notify(DocID, deleted(Commit)). notify_event(created(File, Commit)) :- atom_concat('gitty:', File, DocID), notify(DocID, created(Commit)). % chat message notify_event(chat(Message)) :- notify(Message.docid, chat(Message)). %! event_generator(+Event, -ProfileID) is semidet. % % True when ProfileID refers to the user that initiated Event. event_generator(updated(Commit), Commit.get(profile_id)). event_generator(deleted(Commit), Commit.get(profile_id)). event_generator(forked(_, Commit), Commit.get(profile_id)). /******************************* * NOTIFY PEOPLE ONLINE * *******************************/ notify_online(ProfileID, Action, _Options) :- chat_to_profile(ProfileID, \short_notice(Action)). short_notice(updated(Commit)) --> html([\committer(Commit), ' updated ', \file_name(Commit)]). short_notice(deleted(Commit)) --> html([\committer(Commit), ' deleted ', \file_name(Commit)]). short_notice(forked(OldCommit, Commit)) --> html([\committer(Commit), ' forked ', \file_name(OldCommit), ' into ', \file_name(Commit) ]). short_notice(chat(Message)) --> html([\chat_user(Message), " chatted about ", \chat_file(Message)]). file_name(Commit) --> { http_link_to_id(web_storage, path_postfix(Commit.name), HREF) }, html(a(href(HREF), Commit.name)). /******************************* * ADD NOTIFICATIONS TO CHAT * *******************************/ %! notify_in_chat(+DocID, +Action) :- html_meta(html_string(html, -)). notify_in_chat(_, chat(_)) :- !. notify_in_chat(DocID, Action) :- html_string(\chat_notice(Action, Payload), HTML), action_user(Action, User), Message0 = _{ type:"chat-message", class:"update", html:HTML, user:User, create:false }, ( Payload == [] -> Message = Message0 ; Message = Message0.put(payload, Payload) ), chat_about(DocID, Message). html_string(HTML, String) :- phrase(html(HTML), Tokens), delete(Tokens, nl(_), SingleLine), with_output_to(string(String), print_html(SingleLine)). chat_notice(updated(Commit), [_{type:update, name:Name, commit:CommitHash, previous:PrevCommit}]) --> { _{name:Name, commit:CommitHash, previous:PrevCommit} :< Commit }, html([b('Saved'), ' new version: ', \commit_message_summary(Commit)]). chat_notice(deleted(Commit), []) --> html([b('Deleted'), ': ', \commit_message_summary(Commit)]). chat_notice(forked(_OldCommit, Commit), []) --> html([b('Forked'), ' into ', \file_name(Commit), ': ', \commit_message_summary(Commit) ]). chat_notice(created(Commit), []) --> html([b('Created'), ' ', \file_name(Commit), ': ', \commit_message_summary(Commit) ]). commit_message_summary(Commit) --> { Message = Commit.get(commit_message) }, !, html(span(class(['commit-message']), Message)). commit_message_summary(_Commit) --> html(span(class(['no-commit-message']), 'no message')). %! action_user(+Action, -User) is det. % % Describe a user for chat purposes. Such a user is identified by the % `profile_id`, `name` and/or `avatar`. action_user(Action, User) :- action_commit(Action, Commit), findall(Name-Value, commit_user_property(Commit, Name, Value), Pairs), dict_pairs(User, u, Pairs). action_commit(forked(_From, Commit), Commit) :- !. action_commit(Action, Commit) :- arg(1, Action, Commit). commit_user_property(Commit, Name, Value) :- Profile = Commit.get(profile_id), !, profile_user_property(Profile, Commit, Name, Value). commit_user_property(Commit, name, Name) :- Name = Commit.get(author). commit_user_property(Commit, avatar, Avatar) :- Avatar = Commit.get(avatar). profile_user_property(ProfileID, _, profile_id, ProfileID). profile_user_property(_, Commit, name, Commit.get(author)). profile_user_property(ProfileID, Commit, avatar, Avatar) :- ( profile_property(ProfileID, avatar(Avatar)) -> true ; Avatar = Commit.get(avatar) ). /******************************* * EMAIL * *******************************/ % ! notify_by_mail(+Profile, +DocID, +Action, +FollowOptions) is semidet. % % Send a notification by mail. Optionally schedules the message to be % send later. % % @tbd: if sending fails, should we queue the message? notify_by_mail(Profile, DocID, Action, Options) :- profile_property(Profile, email(Email)), profile_property(Profile, email_notifications(When)), When \== never, must_notify(Action, Options), ( When == immediate -> debug(notify(email), 'Sending notification mail to ~p', [Profile]), send_notification_mail(Profile, DocID, Email, Action) ; debug(notify(email), 'Queing notification mail to ~p', [Profile]), queue_event(Profile, DocID, Action) ). must_notify(chat(Message), Options) :- !, memberchk(chat, Options), \+ Message.get(class) == "update". must_notify(_, Options) :- memberchk(update, Options). % ! send_notification_mail(+Profile, +DocID, +Email, +Action) is semidet. % % Actually send a notification mail. Fails if Profile has no mail % address or does not want to be notified by email. send_notification_mail(Profile, DocID, Email, Action) :- phrase(subject(Action), Codes), string_codes(Subject, Codes), smtp_send_html(Email, \mail_message(Profile, DocID, Action), [ subject(Subject) ]). subject(Action) --> subject_action(Action). subject_action(updated(Commit)) --> txt_commit_file(Commit), " updated by ", txt_committer(Commit). subject_action(deleted(Commit)) --> txt_commit_file(Commit), " deleted by ", txt_committer(Commit). subject_action(forked(_, Commit)) --> txt_commit_file(Commit), " forked by ", txt_committer(Commit). subject_action(chat(Message)) --> txt_chat_user(Message), " chatted about ", txt_chat_file(Message). /******************************* * STYLE * *******************************/ style --> email_style, notify_style. notify_style --> html({|html|| |}). /******************************* * HTML BODY * *******************************/ %! message(+ProfileID, +DocID, +Action)// mail_message(ProfileID, DocID, Action) --> dear(ProfileID), notification(Action), unsubscribe_options(ProfileID, DocID, Action), signature, style. notification(updated(Commit)) --> html(p(['The file ', \global_commit_file(Commit), ' has been updated by ', \committer(Commit), '.'])), commit_message(Commit). notification(forked(OldCommit, Commit)) --> html(p(['The file ', \global_commit_file(OldCommit), ' has been forked into ', \global_commit_file(Commit), ' by ', \committer(Commit), '.'])), commit_message(Commit). notification(deleted(Commit)) --> html(p(['The file ', \global_commit_file(Commit), ' has been deleted by ', \committer(Commit), '.'])), commit_message(Commit). notification(chat(Message)) --> html(p([\chat_user(Message), " chatted about ", \chat_file(Message)])), chat_message(Message). global_commit_file(Commit) --> global_gitty_link(Commit.name). global_gitty_link(File) --> { public_url(web_storage, path_postfix(File), HREF, []) }, html(a(href(HREF), File)). committer(Commit) --> { ProfileID = Commit.get(profile_id) }, !, profile_name(ProfileID). committer(Commit) --> html(Commit.get(owner)). commit_message(Commit) --> { Message = Commit.get(commit_message) }, !, html(p(class(['commit-message', block]), Message)). commit_message(_Commit) --> html(p(class(['no-commit-message', block]), 'No message')). chat_file(Message) --> global_docid_link(Message.docid). global_docid_link(DocID) --> { string_concat("gitty:", File, DocID) }, global_gitty_link(File). chat_user(Message) --> { User = Message.get(user).get(name) }, !, html(User). chat_user(_Message) --> html("Someone"). chat_message(Message) --> (chat_text(Message) -> [] ; []), (chat_payloads(Message.get(payload)) -> [] ; []). chat_text(Message) --> html(p(class([chat,block]), Message.get(text))). chat_payloads([]) --> []. chat_payloads([H|T]) --> chat_payload(H), chat_payloads(T). chat_payload(PayLoad) --> { atom_string(Type, PayLoad.get(type)) }, chat_payload(Type, PayLoad), !. chat_payload(_) --> []. chat_payload(query, PayLoad) --> html(div(class(query), [ div(class('query-title'), 'Query'), pre(class([query, block]), PayLoad.get(query)) ])). chat_payload(about, PayLoad) --> html(div(class(about), [ 'About file ', \global_docid_link(PayLoad.get(docid)) ])). chat_payload(Type, _) --> html(p(['Unknown payload of type ~q'-[Type]])). /******************************* * UNSUBSCRIBE * *******************************/ unsubscribe_options(ProfileID, DocID, _) --> html(div(class(nofollow), [ 'Stop following ', \nofollow_link(ProfileID, DocID, [chat]), '||', \nofollow_link(ProfileID, DocID, [update]), '||', \nofollow_link(ProfileID, DocID, [chat,update]), ' about this document' ])). nofollow_link(ProfileID, DocID, What) --> email_action_link(\nofollow_link_label(What), nofollow_page(ProfileID, DocID, What), nofollow(ProfileID, DocID, What), []). nofollow_link_label([chat]) --> html(chats). nofollow_link_label([update]) --> html(updates). nofollow_link_label([chat, update]) --> html('all notifications'). nofollow_done([chat]) --> html(chat). nofollow_done([update]) --> html(update). nofollow_done([chat, update]) --> html('any notifications'). nofollow_page(ProfileID, DocID, What, _Request) :- reply_html_page( email_confirmation, title('SWISH -- Stopped following'), [ \email_style, \dear(ProfileID), p(['You will no longer receive ', \nofollow_done(What), 'notifications about ', \docid_link(DocID), '. ', 'You can reactivate following this document using the \c File/Follow ... menu in SWISH. You can specify whether \c and when you like to receive email notifications from your \c profile page.' ]), \signature ]). docid_link(DocID) --> { atom_concat('gitty:', File, DocID), http_link_to_id(web_storage, path_postfix(File), HREF) }, !, html(a(href(HREF), File)). docid_link(DocID) --> html(DocID). /******************************* * TEXT RULES ON GITTY COMMITS * *******************************/ txt_commit_file(Commit) --> write(Commit.name). txt_committer(Commit) --> { ProfileID = Commit.get(profile_id) }, !, txt_profile_name(ProfileID). txt_committer(Commit) --> write(Commit.get(owner)), !. /******************************* * RULES ON GITTY COMMITS * *******************************/ txt_profile_name(ProfileID) --> { profile_property(ProfileID, name(Name)) }, write(Name). /******************************* * RULES ON CHAT MESSAGES * *******************************/ txt_chat_user(Message) --> { User = Message.get(user).get(name) }, !, write(User). txt_chat_user(_Message) --> "Someone". txt_chat_file(Message) --> { string_concat("gitty:", File, Message.docid) }, !, write(File). /******************************* * BASICS * *******************************/ write(Term, Head, Tail) :- format(codes(Head, Tail), '~w', [Term]). /******************************* * HTTP HANDLING * *******************************/ :- http_handler(swish(follow/options), follow_file_options, [ id(follow_file_options) ]). :- http_handler(swish(follow/save), save_follow_file, [ id(save_follow_file) ]). %! follow_file_options(+Request) % % Edit the file following options for the current user. follow_file_options(Request) :- http_parameters(Request, [ docid(DocID, [atom]) ]), http_in_session(_SessionID), http_session_data(profile_id(ProfileID)), !, ( profile_property(ProfileID, email_notifications(When)) -> true ; existence_error(profile_property, email_notifications) ), ( following(DocID, ProfileID, Follow) -> true ; Follow = [] ), follow_file_widgets(DocID, When, Follow, Widgets), reply_html_page( title('Follow file options'), \bt_form(Widgets, [ class('form-horizontal'), label_columns(sm-3) ])). follow_file_options(_Request) :- reply_html_page( title('Follow file options'), [ p('You must be logged in to follow a file'), \bt_form([ button_group( [ button(cancel, button, [ type(danger), data([dismiss(modal)]) ]) ], []) ], [ class('form-horizontal'), label_columns(sm-3) ]) ]). :- multifile user_profile:attribute/3. follow_file_widgets(DocID, When, Follow, [ hidden(docid, DocID), checkboxes(follow, [update,chat], [value(Follow)]), select(email_notifications, NotificationOptions, [value(When)]) | Buttons ]) :- user_profile:attribute(email_notifications, oneof(NotificationOptions), _), buttons(Buttons). buttons( [ button_group( [ button(save, submit, [ type(primary), data([action(SaveHREF)]) ]), button(cancel, button, [ type(danger), data([dismiss(modal)]) ]) ], [ ]) ]) :- http_link_to_id(save_follow_file, [], SaveHREF). %! save_follow_file(+Request) % % Save the follow file options save_follow_file(Request) :- http_read_json_dict(Request, Dict), debug(profile(update), 'Got ~p', [Dict]), http_in_session(_SessionID), http_session_data(profile_id(ProfileID)), debug(notify(options), 'Set follow options to ~p', [Dict]), set_profile(ProfileID, email_notifications=Dict.get(email_notifications)), follow(Dict.get(docid), ProfileID, Dict.get(follow)), reply_json_dict(_{status:success}).