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'). 62
77
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 90
102
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)]).
125
134
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)]).
157
161
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.
206
210
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).
224
228
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).
251
253
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).
258
262
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 ).
308
309
326
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).
336
341
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)).
385
389
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 421
423
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')).
470
475
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 587
589
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) ]). 786
790
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).
858
862
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})