35
36:- module(swish_notify,
37 [ follow/3, 38 notify/2 39 ]). 40:- use_module(library(settings)). 41:- use_module(library(persistency)). 42:- use_module(library(broadcast)). 43:- use_module(library(lists)). 44:- use_module(library(readutil)). 45:- use_module(library(debug)). 46:- use_module(library(error)). 47:- use_module(library(apply)). 48:- use_module(library(http/html_write)). 49:- use_module(library(http/http_session)). 50:- use_module(library(http/http_dispatch)). 51:- use_module(library(http/http_parameters)). 52:- use_module(library(http/http_json)). 53:- use_module(library(redis)). 54
55:- use_module(library(user_profile)). 56
57:- use_module(email). 58:- use_module('../bootstrap'). 59:- use_module('../storage'). 60:- use_module('../chat'). 61:- use_module('../config').
78:- setting(database, callable, data('notify.db'),
79 "Database holding notifications"). 80:- setting(queue, callable, data('notify-queue.db'),
81 "File holding queued messages"). 82:- setting(daily, compound, 04:00,
83 "Time at which to send daily messages"). 84
85:- meta_predicate try(0). 86
87
103redis_docid_key(DocId, Server, Key) :-
104 swish_config(redis, Server),
105 swish_config(redis_prefix, Prefix),
106 atomic_list_concat([Prefix, notify, docid, DocId], :, Key).
107
108redis_queue_key(Server, Key) :-
109 swish_config(redis, Server),
110 swish_config(redis_prefix, Prefix),
111 atomic_list_concat([Prefix, notify, queue], :, Key).
112
113:- persistent
114 follower(docid:atom,
115 profile:atom,
116 options:list(oneof([update,chat]))). 117
118notify_open_db :-
119 db_attached(_),
120 !.
121notify_open_db :-
122 setting(database, Spec),
123 absolute_file_name(Spec, Path, [access(write)]),
124 db_attach(Path, [sync(close)]).
135queue_event(Profile, DocID, Action) :-
136 queue_event(Profile, DocID, Action, new).
137
138queue_event(Profile, DocID, Action, Status) :-
139 redis_queue_key(Server, Key),
140 !,
141 redis(Server, rpush(Key, notify(Profile, DocID, Action, Status) as prolog)).
142queue_event(Profile, DocID, Action, Status) :-
143 queue_file(Path),
144 with_mutex(swish_notify,
145 queue_event_sync(Path, Profile, DocID, Action, Status)),
146 start_mail_scheduler.
147
148queue_event_sync(Path, Profile, DocID, Action, Status) :-
149 setup_call_cleanup(
150 open(Path, append, Out, [encoding(utf8)]),
151 format(Out, '~q.~n', [notify(Profile, DocID, Action, Status)]),
152 close(Out)).
153
154queue_file(Path) :-
155 setting(queue, Spec),
156 absolute_file_name(Spec, Path, [access(write)]).
162send_queued_mails :-
163 redis_queue_key(Server, Key),
164 !,
165 redis_send_queued_mails(Server, Key).
166send_queued_mails :-
167 queue_file(Path),
168 exists_file(Path), !,
169 atom_concat(Path, '.sending', Tmp),
170 with_mutex(swish_notify, rename_file(Path, Tmp)),
171 read_file_to_terms(Tmp, Terms, [encoding(utf8)]),
172 forall(member(Term, Terms),
173 send_queued(Term)),
174 delete_file(Tmp).
175send_queued_mails.
176
177
178redis_send_queued_mails(Server, Key) :-
179 ( redis(Server, lpop(Key), Term)
180 -> send_queued(Term),
181 redis_send_queued_mails(Server, Key)
182 ; true
183 ).
184
185send_queued(notify(Profile, DocID, Action, Status)) :-
186 profile_property(Profile, email(Email)),
187 profile_property(Profile, email_notifications(When)),
188 When \== never, !,
189 ( catch(send_notification_mail(Profile, DocID, Email, Action),
190 Error, true)
191 -> ( var(Error)
192 -> true
193 ; update_status(Status, Error, NewStatus)
194 -> queue_event(Profile, Action, NewStatus)
195 ; true
196 )
197 ; update_status(Status, failed, NewStatus)
198 -> queue_event(Profile, DocID, Action, NewStatus)
199 ; true
200 ).
201
202update_status(new, Status, retry(3, Status)).
203update_status(retry(Count0, _), Status, retry(Count, Status)) :-
204 Count0 > 0,
205 Count is Count0 - 1.
211:- dynamic mail_scheduler_running/0. 212
213start_mail_scheduler :-
214 mail_scheduler_running,
215 !.
216start_mail_scheduler :-
217 catch(thread_create(mail_main, _,
218 [ alias(mail_scheduler),
219 detached(true),
220 at_exit(retractall(mail_scheduler_running))
221 ]),
222 error(permission_error(create, thread, mail_scheduler), _),
223 true).
229mail_main :-
230 asserta(mail_scheduler_running),
231 repeat,
232 next_send_queue_time(T),
233 get_time(Now),
234 Sleep is T-Now,
235 sleep(Sleep),
236 thread_create(send_queued_mails, _,
237 [ detached(true),
238 alias(send_queued_mails)
239 ]),
240 fail.
241
242next_send_queue_time(T) :-
243 get_time(Now),
244 stamp_date_time(Now, date(Y,M,D0,H0,_M,_S,Off,TZ,DST), local),
245 setting(daily, HH:MM),
246 ( H0 @< HH
247 -> D = D0
248 ; D is D0+1
249 ),
250 date_time_stamp(date(Y,M,D,HH,MM,0,Off,TZ,DST), T).
254following(DocID, ProfileID, Flags) :-
255 redis_docid_key(DocID, Server, Key),
256 redis(Server, hgetall(Key), Pairs as pairs(atom, auto)),
257 member(ProfileID-Flags, Pairs).
263follow(DocID, ProfileID, Flags) :-
264 redis_docid_key(DocID, Server, Key),
265 !,
266 ( Flags == []
267 -> redis(Server, hdel(Key, ProfileID))
268 ; maplist(to_atom, Flags, Options),
269 redis(Server, hset(Key, ProfileID, Options as prolog))
270 ).
271follow(DocID, ProfileID, Flags) :-
272 to_atom(DocID, DocIDA),
273 to_atom(ProfileID, ProfileIDA),
274 maplist(to_atom, Flags, Options),
275 notify_open_db,
276 ( follower(DocIDA, ProfileIDA, OldOptions)
277 -> ( OldOptions == Options
278 -> true
279 ; retractall_follower(DocIDA, ProfileIDA, _),
280 ( Options \== []
281 -> assert_follower(DocIDA, ProfileIDA, Options)
282 ; true
283 )
284 )
285 ; Options \== []
286 -> assert_follower(DocIDA, ProfileIDA, Options)
287 ; true
288 ).
289
290nofollow(DocID, ProfileID, Flags) :-
291 redis_docid_key(DocID, Server, Key),
292 !,
293 maplist(to_atom, Flags, Options),
294 ( redis(Server, hget(Key, ProfileID), OldOptions)
295 -> subtract(OldOptions, Options, NewOptions),
296 follow(DocID, ProfileID, NewOptions)
297 ; true
298 ).
299nofollow(DocID, ProfileID, Flags) :-
300 to_atom(DocID, DocIDA),
301 to_atom(ProfileID, ProfileIDA),
302 maplist(to_atom, Flags, Options),
303 ( follower(DocIDA, ProfileIDA, OldOptions)
304 -> subtract(OldOptions, Options, NewOptions),
305 follow(DocID, ProfileID, NewOptions)
306 ; true
307 ).
327notify(DocID, Action) :-
328 to_atom(DocID, DocIDA),
329 try(notify_in_chat(DocIDA, Action)),
330 notify_open_db,
331 forall(following(DocIDA, Profile, Options),
332 notify_user(Profile, DocIDA, Action, Options)).
333
334to_atom(Text, Atom) :-
335 atom_string(Atom, Text).
342notify_user(Profile, _, Action, _Options) :- 343 event_generator(Action, Profile),
344 debug(notify(self), 'Notification to self ~p', [Profile]),
345 \+ debugging(notify_self),
346 !.
347notify_user(Profile, DocID, Action, Options) :-
348 try(notify_online(Profile, Action, Options)),
349 try(notify_by_mail(Profile, DocID, Action, Options)).
350
351try(Goal) :-
352 catch(Goal, Error, print_message(error, Error)),
353 !.
354try(_0Goal) :-
355 debug(notify(fail), 'Failed: ~p', [_0Goal]).
356
357
358 361
362:- unlisten(swish(_)),
363 listen(swish(Event), notify_event(Event)). 364
366notify_event(follow(DocID, ProfileID, Options)) :-
367 follow(DocID, ProfileID, Options).
369notify_event(updated(File, Commit)) :-
370 storage_meta_data(Commit.get(previous), OldCommit),
371 ( atom_concat('gitty:', OldCommit.name, DocID)
372 -> notify(DocID, updated(Commit))
373 ; atom_concat('gitty:', File, DocID),
374 notify(DocID, forked(OldCommit, Commit))
375 ).
376notify_event(deleted(File, Commit)) :-
377 atom_concat('gitty:', File, DocID),
378 notify(DocID, deleted(Commit)).
379notify_event(created(File, Commit)) :-
380 atom_concat('gitty:', File, DocID),
381 notify(DocID, created(Commit)).
383notify_event(chat(Message)) :-
384 notify(Message.docid, chat(Message)).
390event_generator(updated(Commit), Commit.get(profile_id)).
391event_generator(deleted(Commit), Commit.get(profile_id)).
392event_generator(forked(_, Commit), Commit.get(profile_id)).
393
394
395 398
399notify_online(ProfileID, Action, _Options) :-
400 chat_to_profile(ProfileID, \short_notice(Action)).
401
402short_notice(updated(Commit)) -->
403 html([\committer(Commit), ' updated ', \file_name(Commit)]).
404short_notice(deleted(Commit)) -->
405 html([\committer(Commit), ' deleted ', \file_name(Commit)]).
406short_notice(forked(OldCommit, Commit)) -->
407 html([\committer(Commit), ' forked ', \file_name(OldCommit),
408 ' into ', \file_name(Commit)
409 ]).
410short_notice(chat(Message)) -->
411 html([\chat_user(Message), " chatted about ", \chat_file(Message)]).
412
413file_name(Commit) -->
414 { http_link_to_id(web_storage, path_postfix(Commit.name), HREF) },
415 html(a(href(HREF), Commit.name)).
416
417
418
424:- html_meta(html_string(html, -)). 425
426notify_in_chat(_, chat(_)) :-
427 !.
428notify_in_chat(DocID, Action) :-
429 html_string(\chat_notice(Action, Payload), HTML),
430 action_user(Action, User),
431 Message0 = _{ type:"chat-message",
432 class:"update",
433 html:HTML,
434 user:User,
435 create:false
436 },
437 ( Payload == []
438 -> Message = Message0
439 ; Message = Message0.put(payload, Payload)
440 ),
441 chat_about(DocID, Message).
442
443
444html_string(HTML, String) :-
445 phrase(html(HTML), Tokens),
446 delete(Tokens, nl(_), SingleLine),
447 with_output_to(string(String), print_html(SingleLine)).
448
449
450chat_notice(updated(Commit), [_{type:update, name:Name,
451 commit:CommitHash, previous:PrevCommit}]) -->
452 { _{name:Name, commit:CommitHash, previous:PrevCommit} :< Commit },
453 html([b('Saved'), ' new version: ', \commit_message_summary(Commit)]).
454chat_notice(deleted(Commit), []) -->
455 html([b('Deleted'), ': ', \commit_message_summary(Commit)]).
456chat_notice(forked(_OldCommit, Commit), []) -->
457 html([b('Forked'), ' into ', \file_name(Commit), ': ',
458 \commit_message_summary(Commit)
459 ]).
460chat_notice(created(Commit), []) -->
461 html([b('Created'), ' ', \file_name(Commit), ': ',
462 \commit_message_summary(Commit)
463 ]).
464
465commit_message_summary(Commit) -->
466 { Message = Commit.get(commit_message) }, !,
467 html(span(class(['commit-message']), Message)).
468commit_message_summary(_Commit) -->
469 html(span(class(['no-commit-message']), 'no message')).
476action_user(Action, User) :-
477 action_commit(Action, Commit),
478 findall(Name-Value, commit_user_property(Commit, Name, Value), Pairs),
479 dict_pairs(User, u, Pairs).
480
481action_commit(forked(_From, Commit), Commit) :-
482 !.
483action_commit(Action, Commit) :-
484 arg(1, Action, Commit).
485
486commit_user_property(Commit, Name, Value) :-
487 Profile = Commit.get(profile_id),
488 !,
489 profile_user_property(Profile, Commit, Name, Value).
490commit_user_property(Commit, name, Name) :-
491 Name = Commit.get(author).
492commit_user_property(Commit, avatar, Avatar) :-
493 Avatar = Commit.get(avatar).
494
495profile_user_property(ProfileID, _, profile_id, ProfileID).
496profile_user_property(_, Commit, name, Commit.get(author)).
497profile_user_property(ProfileID, Commit, avatar, Avatar) :-
498 ( profile_property(ProfileID, avatar(Avatar))
499 -> true
500 ; Avatar = Commit.get(avatar)
501 ).
502
503
504 507
514
515notify_by_mail(Profile, DocID, Action, Options) :-
516 profile_property(Profile, email(Email)),
517 profile_property(Profile, email_notifications(When)),
518 When \== never,
519 must_notify(Action, Options),
520 ( When == immediate
521 -> debug(notify(email), 'Sending notification mail to ~p', [Profile]),
522 send_notification_mail(Profile, DocID, Email, Action)
523 ; debug(notify(email), 'Queing notification mail to ~p', [Profile]),
524 queue_event(Profile, DocID, Action)
525 ).
526
527must_notify(chat(Message), Options) :- !,
528 memberchk(chat, Options),
529 \+ Message.get(class) == "update".
530must_notify(_, Options) :-
531 memberchk(update, Options).
532
537
538send_notification_mail(Profile, DocID, Email, Action) :-
539 phrase(subject(Action), Codes),
540 string_codes(Subject, Codes),
541 smtp_send_html(Email, \mail_message(Profile, DocID, Action),
542 [ subject(Subject)
543 ]).
544
545subject(Action) -->
546 subject_action(Action).
547
548subject_action(updated(Commit)) -->
549 txt_commit_file(Commit), " updated by ", txt_committer(Commit).
550subject_action(deleted(Commit)) -->
551 txt_commit_file(Commit), " deleted by ", txt_committer(Commit).
552subject_action(forked(_, Commit)) -->
553 txt_commit_file(Commit), " forked by ", txt_committer(Commit).
554subject_action(chat(Message)) -->
555 txt_chat_user(Message), " chatted about ", txt_chat_file(Message).
556
557
558 561
562style -->
563 email_style,
564 notify_style.
565
566notify_style -->
567 html({|html||
568<style>
569 .block {margin-left: 2em;}
570p.commit-message,
571p.chat {color: darkgreen;}
572p.nocommit-message {color: orange;}
573pre.query {}
574div.query {margin-top:2em; border-top: 1px solid #888;}
575div.query-title {font-size: 80%; color: #888;}
576div.nofollow {margin-top:2em; border-top: 1px solid #888;
577 font-size: 80%; color: #888; }
578</style>
579 |}).
580
581
582
583
584
590mail_message(ProfileID, DocID, Action) -->
591 dear(ProfileID),
592 notification(Action),
593 unsubscribe_options(ProfileID, DocID, Action),
594 signature,
595 style.
596
597notification(updated(Commit)) -->
598 html(p(['The file ', \global_commit_file(Commit),
599 ' has been updated by ', \committer(Commit), '.'])),
600 commit_message(Commit).
601notification(forked(OldCommit, Commit)) -->
602 html(p(['The file ', \global_commit_file(OldCommit),
603 ' has been forked into ', \global_commit_file(Commit), ' by ',
604 \committer(Commit), '.'])),
605 commit_message(Commit).
606notification(deleted(Commit)) -->
607 html(p(['The file ', \global_commit_file(Commit),
608 ' has been deleted by ', \committer(Commit), '.'])),
609 commit_message(Commit).
610notification(chat(Message)) -->
611 html(p([\chat_user(Message), " chatted about ", \chat_file(Message)])),
612 chat_message(Message).
613
614global_commit_file(Commit) -->
615 global_gitty_link(Commit.name).
616
617global_gitty_link(File) -->
618 { public_url(web_storage, path_postfix(File), HREF, []) },
619 html(a(href(HREF), File)).
620
621committer(Commit) -->
622 { ProfileID = Commit.get(profile_id) }, !,
623 profile_name(ProfileID).
624committer(Commit) -->
625 html(Commit.get(owner)).
626
627commit_message(Commit) -->
628 { Message = Commit.get(commit_message) }, !,
629 html(p(class(['commit-message', block]), Message)).
630commit_message(_Commit) -->
631 html(p(class(['no-commit-message', block]), 'No message')).
632
633chat_file(Message) -->
634 global_docid_link(Message.docid).
635
636global_docid_link(DocID) -->
637 { string_concat("gitty:", File, DocID)
638 },
639 global_gitty_link(File).
640
641chat_user(Message) -->
642 { User = Message.get(user).get(name) },
643 !,
644 html(User).
645chat_user(_Message) -->
646 html("Someone").
647
648chat_message(Message) -->
649 (chat_text(Message) -> [] ; []),
650 (chat_payloads(Message.get(payload)) -> [] ; []).
651
652chat_text(Message) -->
653 html(p(class([chat,block]), Message.get(text))).
654
655chat_payloads([]) --> [].
656chat_payloads([H|T]) --> chat_payload(H), chat_payloads(T).
657
658chat_payload(PayLoad) -->
659 { atom_string(Type, PayLoad.get(type)) },
660 chat_payload(Type, PayLoad),
661 !.
662chat_payload(_) --> [].
663
664chat_payload(query, PayLoad) -->
665 html(div(class(query),
666 [ div(class('query-title'), 'Query'),
667 pre(class([query, block]), PayLoad.get(query))
668 ])).
669chat_payload(about, PayLoad) -->
670 html(div(class(about),
671 [ 'About file ', \global_docid_link(PayLoad.get(docid)) ])).
672chat_payload(Type, _) -->
673 html(p(['Unknown payload of type ~q'-[Type]])).
674
675
676 679
680unsubscribe_options(ProfileID, DocID, _) -->
681 html(div(class(nofollow),
682 [ 'Stop following ',
683 \nofollow_link(ProfileID, DocID, [chat]), '||',
684 \nofollow_link(ProfileID, DocID, [update]), '||',
685 \nofollow_link(ProfileID, DocID, [chat,update]),
686 ' about this document'
687 ])).
688
689nofollow_link(ProfileID, DocID, What) -->
690 email_action_link(\nofollow_link_label(What),
691 nofollow_page(ProfileID, DocID, What),
692 nofollow(ProfileID, DocID, What),
693 []).
694
695nofollow_link_label([chat]) --> html(chats).
696nofollow_link_label([update]) --> html(updates).
697nofollow_link_label([chat, update]) --> html('all notifications').
698
699nofollow_done([chat]) --> html(chat).
700nofollow_done([update]) --> html(update).
701nofollow_done([chat, update]) --> html('any notifications').
702
703nofollow_page(ProfileID, DocID, What, _Request) :-
704 reply_html_page(
705 email_confirmation,
706 title('SWISH -- Stopped following'),
707 [ \email_style,
708 \dear(ProfileID),
709 p(['You will no longer receive ', \nofollow_done(What),
710 'notifications about ', \docid_link(DocID), '. ',
711 'You can reactivate following this document using the \c
712 File/Follow ... menu in SWISH. You can specify whether \c
713 and when you like to receive email notifications from your \c
714 profile page.'
715 ]),
716 \signature
717 ]).
718
719docid_link(DocID) -->
720 { atom_concat('gitty:', File, DocID),
721 http_link_to_id(web_storage, path_postfix(File), HREF)
722 },
723 !,
724 html(a(href(HREF), File)).
725docid_link(DocID) -->
726 html(DocID).
727
728
729 732
733txt_commit_file(Commit) -->
734 write(Commit.name).
735
736txt_committer(Commit) -->
737 { ProfileID = Commit.get(profile_id) }, !,
738 txt_profile_name(ProfileID).
739txt_committer(Commit) -->
740 write(Commit.get(owner)), !.
741
742
743
744 747
748txt_profile_name(ProfileID) -->
749 { profile_property(ProfileID, name(Name)) },
750 write(Name).
751
752
753 756
757txt_chat_user(Message) -->
758 { User = Message.get(user).get(name) },
759 !,
760 write(User).
761txt_chat_user(_Message) -->
762 "Someone".
763
764txt_chat_file(Message) -->
765 { string_concat("gitty:", File, Message.docid) },
766 !,
767 write(File).
768
769
770 773
774write(Term, Head, Tail) :-
775 format(codes(Head, Tail), '~w', [Term]).
776
777
778 781
782:- http_handler(swish(follow/options), follow_file_options,
783 [ id(follow_file_options) ]). 784:- http_handler(swish(follow/save), save_follow_file,
785 [ id(save_follow_file) ]).
791follow_file_options(Request) :-
792 http_parameters(Request,
793 [ docid(DocID, [atom])
794 ]),
795 http_in_session(_SessionID),
796 http_session_data(profile_id(ProfileID)), !,
797 ( profile_property(ProfileID, email_notifications(When))
798 -> true
799 ; existence_error(profile_property, email_notifications)
800 ),
801
802 ( following(DocID, ProfileID, Follow)
803 -> true
804 ; Follow = []
805 ),
806
807 follow_file_widgets(DocID, When, Follow, Widgets),
808
809 reply_html_page(
810 title('Follow file options'),
811 \bt_form(Widgets,
812 [ class('form-horizontal'),
813 label_columns(sm-3)
814 ])).
815follow_file_options(_Request) :-
816 reply_html_page(
817 title('Follow file options'),
818 [ p('You must be logged in to follow a file'),
819 \bt_form([ button_group(
820 [ button(cancel, button,
821 [ type(danger),
822 data([dismiss(modal)])
823 ])
824 ], [])
825 ],
826 [ class('form-horizontal'),
827 label_columns(sm-3)
828 ])
829 ]).
830
831:- multifile
832 user_profile:attribute/3. 833
834follow_file_widgets(DocID, When, Follow,
835 [ hidden(docid, DocID),
836 checkboxes(follow, [update,chat], [value(Follow)]),
837 select(email_notifications, NotificationOptions, [value(When)])
838 | Buttons
839 ]) :-
840 user_profile:attribute(email_notifications, oneof(NotificationOptions), _),
841 buttons(Buttons).
842
843buttons(
844 [ button_group(
845 [ button(save, submit,
846 [ type(primary),
847 data([action(SaveHREF)])
848 ]),
849 button(cancel, button,
850 [ type(danger),
851 data([dismiss(modal)])
852 ])
853 ],
854 [
855 ])
856 ]) :-
857 http_link_to_id(save_follow_file, [], SaveHREF).
863save_follow_file(Request) :-
864 http_read_json_dict(Request, Dict),
865 debug(profile(update), 'Got ~p', [Dict]),
866 http_in_session(_SessionID),
867 http_session_data(profile_id(ProfileID)),
868 debug(notify(options), 'Set follow options to ~p', [Dict]),
869 set_profile(ProfileID, email_notifications=Dict.get(email_notifications)),
870 follow(Dict.get(docid), ProfileID, Dict.get(follow)),
871 reply_json_dict(_{status:success})
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:
*/