View source with raw comments or as raw
    1/*  Part of SWISH
    2
    3    Author:        Jan Wielemaker
    4    E-mail:        J.Wielemaker@cs.vu.nl
    5    WWW:           http://www.swi-prolog.org
    6    Copyright (C): 2016-2018, VU University Amsterdam
    7			      CWI Amsterdam
    8    All rights reserved.
    9
   10    Redistribution and use in source and binary forms, with or without
   11    modification, are permitted provided that the following conditions
   12    are met:
   13
   14    1. Redistributions of source code must retain the above copyright
   15       notice, this list of conditions and the following disclaimer.
   16
   17    2. Redistributions in binary form must reproduce the above copyright
   18       notice, this list of conditions and the following disclaimer in
   19       the documentation and/or other materials provided with the
   20       distribution.
   21
   22    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
   23    "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
   24    LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
   25    FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
   26    COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
   27    INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
   28    BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
   29    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
   30    CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
   31    LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
   32    ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
   33    POSSIBILITY OF SUCH DAMAGE.
   34*/
   35
   36:- module(swish_chat,
   37	  [ chat_broadcast/1,		% +Message
   38	    chat_broadcast/2,		% +Message, +Channel
   39	    chat_to_profile/2,		% +ProfileID, :HTML
   40	    chat_about/2,		% +DocID, +Message
   41
   42	    notifications//1,		% +Options
   43	    broadcast_bell//1		% +Options
   44	  ]).   45:- use_module(library(http/hub)).   46:- use_module(library(http/http_dispatch)).   47:- use_module(library(http/http_session)).   48:- use_module(library(http/http_parameters)).   49:- use_module(library(http/websocket)).   50:- use_module(library(http/json)).   51:- use_module(library(error)).   52:- use_module(library(lists)).   53:- use_module(library(option)).   54:- use_module(library(debug)).   55:- use_module(library(uuid)).   56:- use_module(library(random)).   57:- use_module(library(base64)).   58:- use_module(library(apply)).   59:- use_module(library(broadcast)).   60:- use_module(library(ordsets)).   61:- use_module(library(http/html_write)).   62:- use_module(library(http/http_path)).   63:- if(exists_source(library(user_profile))).   64:- use_module(library(user_profile)).   65:- endif.   66:- use_module(library(aggregate)).   67
   68:- use_module(storage).   69:- use_module(gitty).   70:- use_module(config).   71:- use_module(avatar).   72:- use_module(noble_avatar).   73:- use_module(chatstore).   74:- use_module(authenticate).   75:- use_module(pep).   76:- use_module(content_filter).   77
   78:- html_meta(chat_to_profile(+, html)).

The SWISH collaboration backbone

We have three levels of identity as enumerated below. Note that these form a hierarchy: a particular user may be logged on using multiple browsers which in turn may have multiple SWISH windows opened.

  1. Any open SWISH window has an associated websocket, represented by the identifier returned by hub_add/3.
  2. Any browser, possibly having multiple open SWISH windows, is identified by a session cookie.
  3. The user may be logged in, either based on the cookie or on HTTP authentication. */
   94:- multifile swish_config:config/2.   95
   96swish_config:config(hangout, 'Hangout.swinb').
   97swish_config:config(avatars, svg).		% or 'noble'
   98
   99
  100		 /*******************************
  101		 *	ESTABLISH WEBSOCKET	*
  102		 *******************************/
  103
  104:- http_handler(swish(chat), start_chat, [ id(swish_chat) ]).  105
  106:- meta_predicate must_succeed(0).
 start_chat(+Request)
HTTP handler that establishes a websocket connection where a user gets an avatar and optionally a name.
  113start_chat(Request) :-
  114	authenticate(Request, Identity),
  115	start_chat(Request, [identity(Identity)]).
  116
  117start_chat(Request, Options) :-
  118	authorized(chat(open), Options),
  119	(   http_in_session(Session)
  120	->  CheckLogin = false
  121	;   http_open_session(Session, []),
  122	    CheckLogin = true
  123	),
  124	check_flooding(Session),
  125	http_parameters(Request,
  126			[ avatar(Avatar, [optional(true)]),
  127			  nickname(NickName, [optional(true)]),
  128			  reconnect(Token, [optional(true)])
  129			]),
  130	extend_options([ avatar(Avatar),
  131			 nick_name(NickName),
  132			 reconnect(Token),
  133			 check_login(CheckLogin)
  134		       ], Options, ChatOptions),
  135	debug(chat(websocket), 'Accepting (session ~p)', [Session]),
  136	http_upgrade_to_websocket(
  137	    accept_chat(Session, ChatOptions),
  138	    [ guarded(false),
  139	      subprotocols(['v1.chat.swish.swi-prolog.org', chat])
  140	    ],
  141	    Request).
  142
  143extend_options([], Options, Options).
  144extend_options([H|T0], Options, [H|T]) :-
  145	ground(H), !,
  146	extend_options(T0, Options, T).
  147extend_options([_|T0], Options, T) :-
  148	extend_options(T0, Options, T).
 check_flooding(+Session)
See whether the client associated with a session is flooding us and if so, return a resource error.
  156check_flooding(Session) :-
  157	get_time(Now),
  158	(   http_session_retract(websocket(Score, Last))
  159	->  Passed is Now-Last,
  160	    NewScore is Score*(2**(-Passed/60)) + 10
  161	;   NewScore = 10,
  162	    Passed = 0
  163	),
  164	debug(chat(flooding), 'Flooding score: ~2f (session ~p)',
  165	      [NewScore, Session]),
  166	http_session_assert(websocket(NewScore, Now)),
  167	(   NewScore > 50
  168	->  throw(http_reply(resource_error(
  169				 error(permission_error(reconnect, websocket,
  170							Session),
  171				       websocket(reconnect(Passed, NewScore))))))
  172	;   true
  173	).
 accept_chat(+Session, +Options, +WebSocket)
  177accept_chat(Session, Options, WebSocket) :-
  178	must_succeed(accept_chat_(Session, Options, WebSocket)).
  179
  180accept_chat_(Session, Options, WebSocket) :-
  181	create_chat_room,
  182	(   reconnect_token(WSID, Token, Options),
  183	    retractall(visitor_status(WSID, lost(_))),
  184	    existing_visitor(WSID, Session, Token, TmpUser, UserData),
  185	    hub_add(swish_chat, WebSocket, WSID)
  186	->  Reason = rejoined
  187	;   hub_add(swish_chat, WebSocket, WSID),
  188	    must_succeed(create_visitor(WSID, Session, Token,
  189					TmpUser, UserData, Options)),
  190	    Reason = joined
  191	),
  192	visitor_count(Visitors),
  193	option(check_login(CheckLogin), Options, true),
  194	Msg = _{ type:welcome,
  195		 uid:TmpUser,
  196		 wsid:WSID,
  197		 reconnect:Token,
  198		 visitors:Visitors,
  199		 check_login:CheckLogin
  200	       },
  201	hub_send(WSID, json(UserData.put(Msg))),
  202	must_succeed(chat_broadcast(UserData.put(_{type:Reason,
  203						   visitors:Visitors,
  204						   wsid:WSID}))),
  205	gc_visitors,
  206	debug(chat(websocket), '~w (session ~p, wsid ~p)',
  207	      [Reason, Session, WSID]).
  208
  209
  210reconnect_token(WSID, Token, Options) :-
  211	option(reconnect(Token), Options),
  212	visitor_session(WSID, _, Token), !.
  213
  214must_succeed(Goal) :-
  215	catch(Goal, E, print_message(warning, E)), !.
  216must_succeed(Goal) :-
  217	print_message(warning, goal_failed(Goal)).
  218
  219
  220		 /*******************************
  221		 *	        DATA		*
  222		 *******************************/
 visitor_session(?WSId, ?Session, ?Token)
 session_user(?Session, ?TmpUser)
 visitor_data(?TmpUser, ?UserData:dict)
 subscription(?Session, ?Channel, ?SubChannel)
These predicates represent our notion of visitors.
Arguments:
WSID- is the identifier of the web socket. As we may have to reconnect lost connections, this is may be replaced.
Session- is the session identifier. This is used to connect SWISH actions to WSIDs.
TmpUser- is the ID with which we identify the user for this run. The value is a UUID and thus doesn't reveal the real identity of the user.
UserDict- is a dict that holds information about the real user identity. This can be empty if no information is known about this user.
  242:- dynamic
  243	visitor_status/2,		% WSID, Status
  244	visitor_session/3,		% WSID, Session, Token
  245	session_user/2,			% Session, TmpUser
  246	visitor_data/2,			% TmpUser, Data
  247	subscription/3.			% WSID, Channel, SubChannel
 visitor(?WSID) is nondet
True when WSID should be considered an active visitor.
  253visitor(WSID) :-
  254	visitor_session(WSID, _Session, _Token),
  255	\+ inactive(WSID, 30).
  256
  257visitor_count(Count) :-
  258	aggregate_all(count, visitor(_), Count).
 inactive(+WSID, +Timeout) is semidet
True if WSID is inactive. This means we lost the connection at least Timeout seconds ago.
  265inactive(WSID, Timeout) :-
  266	visitor_status(WSID, lost(Lost)),
  267	get_time(Now),
  268	Now - Lost > Timeout.
 visitor_session(?WSID, ?Session) is nondet
True if websocket WSID is associated with Session.
  274visitor_session(WSID, Session) :-
  275	visitor_session(WSID, Session, _Token).
 wsid_visitor(?WSID, ?Visitor)
True when WSID is associated with Visitor
  281wsid_visitor(WSID, Visitor) :-
  282	nonvar(WSID), !,
  283	visitor_session(WSID, Session),
  284	session_user(Session, Visitor).
  285wsid_visitor(WSID, Visitor) :-
  286	session_user(Session, Visitor),
  287	visitor_session(WSID, Session).
 existing_visitor(+WSID, +Session, +Token, -TmpUser, -UserData) is semidet
True if we are dealing with an existing visitor for which we lost the connection.
  294existing_visitor(WSID, Session, Token, TmpUser, UserData) :-
  295	visitor_session(WSID, Session, Token),
  296	session_user(Session, TmpUser),
  297	visitor_data(TmpUser, UserData), !.
  298existing_visitor(WSID, Session, Token, _, _) :-
  299	retractall(visitor_session(WSID, Session, Token)),
  300	fail.
 create_visitor(+WSID, +Session, ?Token, -TmpUser, -UserData, +Options)
Create a new visitor when a new websocket is established. Options provides information we have about the user:
current_user_info(+Info)
Already logged in user with given information
avatar(Avatar)
Avatar remembered in the browser for this user.
nick_name(NickName)
Nick name remembered in the browser for this user.
  314create_visitor(WSID, Session, Token, TmpUser, UserData, Options) :-
  315	generate_key(Token),
  316	assertz(visitor_session(WSID, Session, Token)),
  317	create_session_user(Session, TmpUser, UserData, Options).
 generate_key(-Key) is det
Generate a random confirmation key
  323generate_key(Key) :-
  324	length(Codes, 16),
  325	maplist(random_between(0,255), Codes),
  326	phrase(base64url(Codes), Encoded),
  327	atom_codes(Key, Encoded).
 destroy_visitor(+WSID)
The web socket WSID has been closed. We should not immediately destroy the temporary user as the browser may soon reconnect due to a page reload or re-establishing the web socket after a temporary network failure. We leave the destruction thereof to the session, but set the session timeout to a fairly short time.
To be done
- We should only inform clients that we have informed about this user.
  340destroy_visitor(WSID) :-
  341	must_be(atom, WSID),
  342	destroy_reason(WSID, Reason),
  343	(   Reason == unload
  344	->  reclaim_visitor(WSID)
  345	;   get_time(Now),
  346	    assertz(visitor_status(WSID, lost(Now)))
  347	),
  348	visitor_count(Count),
  349	chat_broadcast(_{ type:removeUser,
  350			  wsid:WSID,
  351			  reason:Reason,
  352			  visitors:Count
  353			}).
  354
  355destroy_reason(WSID, Reason) :-
  356	retract(visitor_status(WSID, unload)), !,
  357	Reason = unload.
  358destroy_reason(_, close).
 gc_visitors
Reclaim all visitors with whom we have lost the connection and the browser did not reclaim the selection within 5 minutes.
  365:- dynamic last_gc/1.  366
  367gc_visitors :-
  368	last_gc(Last),
  369	get_time(Now),
  370	Now-Last < 300, !.
  371gc_visitors :-
  372	with_mutex(gc_visitors, gc_visitors_sync).
  373
  374gc_visitors_sync :-
  375	get_time(Now),
  376	(   last_gc(Last),
  377	    Now-Last < 300
  378	->  true
  379	;   retractall(last_gc(_)),
  380	    asserta(last_gc(Now)),
  381	    do_gc_visitors
  382	).
  383
  384do_gc_visitors :-
  385	forall(( visitor_session(WSID, _Session, _Token),
  386		 inactive(WSID, 5*60)
  387	       ),
  388	       reclaim_visitor(WSID)).
  389
  390reclaim_visitor(WSID) :-
  391	debug(chat(gc), 'Reclaiming idle ~p', [WSID]),
  392	reclaim_visitor_session(WSID),
  393	retractall(visitor_status(WSID, _Status)),
  394	unsubscribe(WSID, _).
  395
  396reclaim_visitor_session(WSID) :-
  397	forall(retract(visitor_session(WSID, Session, _Token)),
  398		       http_session_retractall(websocket(_, _), Session)).
  399
  400:- if(\+current_predicate(http_session_retractall/2)).  401http_session_retractall(Data, Session) :-
  402	retractall(http_session:session_data(Session, Data)).
  403:- endif.
 create_session_user(+Session, -User, -UserData, +Options)
Associate a user with the session. The user id is a UUID that is not associated with any persistent notion of a user. The destruction is left to the destruction of the session.
  412:- listen(http_session(end(SessionID, _Peer)),
  413	  destroy_session_user(SessionID)).  414
  415create_session_user(Session, TmpUser, UserData, _Options) :-
  416	session_user(Session, TmpUser),
  417	visitor_data(TmpUser, UserData), !.
  418create_session_user(Session, TmpUser, UserData, Options) :-
  419	uuid(TmpUser),
  420	get_visitor_data(UserData, Options),
  421	assertz(session_user(Session, TmpUser)),
  422	assertz(visitor_data(TmpUser, UserData)).
  423
  424destroy_session_user(Session) :-
  425	forall(visitor_session(WSID, Session, _Token),
  426	       inform_session_closed(WSID, Session)),
  427	retractall(visitor_session(_, Session, _)),
  428	forall(retract(session_user(Session, TmpUser)),
  429	       destroy_visitor_data(TmpUser)).
  430
  431destroy_visitor_data(TmpUser) :-
  432	(   retract(visitor_data(TmpUser, Data)),
  433	    release_avatar(Data.get(avatar)),
  434	    fail
  435	;   true
  436	).
  437
  438inform_session_closed(WSID, Session) :-
  439	ignore(hub_send(WSID, json(_{type:session_closed}))),
  440	session_user(Session, TmpUser),
  441	update_visitor_data(TmpUser, _Data, logout).
 update_visitor_data(+TmpUser, +Data, +Reason) is det
Update the user data for the visitor TmpUser to Data. This is rather complicates due to all the defaulting rules. Reason is one of:
To be done
- Create a more declarative description on where the various attributes must come from.
  458update_visitor_data(TmpUser, _Data, logout) :- !,
  459	anonymise_user_data(TmpUser, NewData),
  460	set_visitor_data(TmpUser, NewData, logout).
  461update_visitor_data(TmpUser, Data, Reason) :-
  462	profile_reason(Reason), !,
  463	(   visitor_data(TmpUser, Old)
  464	;   Old = v{}
  465	),
  466	copy_profile([name,avatar,email], Data, Old, New),
  467	set_visitor_data(TmpUser, New, Reason).
  468update_visitor_data(TmpUser, _{name:Name}, 'set-nick-name') :- !,
  469	visitor_data(TmpUser, Old),
  470	set_nick_name(Old, Name, New),
  471	set_visitor_data(TmpUser, New, 'set-nick-name').
  472update_visitor_data(TmpUser, Data, Reason) :-
  473	set_visitor_data(TmpUser, Data, Reason).
  474
  475profile_reason('profile-edit').
  476profile_reason('login').
  477
  478copy_profile([], _, Data, Data).
  479copy_profile([H|T], New, Data0, Data) :-
  480	copy_profile_field(H, New, Data0, Data1),
  481	copy_profile(T, New, Data1, Data).
  482
  483copy_profile_field(avatar, New, Data0, Data) :-	!,
  484	(   Data1 = Data0.put(avatar,New.get(avatar))
  485	->  Data  = Data1.put(avatar_source, profile)
  486	;   email_gravatar(New.get(email), Avatar),
  487	    valid_gravatar(Avatar)
  488	->  Data = Data0.put(_{avatar:Avatar,avatar_source:email})
  489	;   Avatar = Data0.get(anonymous_avatar)
  490	->  Data = Data0.put(_{avatar:Avatar,avatar_source:client})
  491	;   noble_avatar_url(Avatar, []),
  492	    Data = Data0.put(_{avatar:Avatar,avatar_source:generated,
  493			       anonymous_avatar:Avatar
  494			      })
  495	).
  496copy_profile_field(email, New, Data0, Data) :- !,
  497	(   NewMail = New.get(email)
  498	->  update_avatar_from_email(NewMail, Data0, Data1),
  499	    Data = Data1.put(email, NewMail)
  500	;   update_avatar_from_email('', Data0, Data1),
  501	    (	del_dict(email, Data1, _, Data)
  502	    ->	true
  503	    ;	Data = Data1
  504	    )
  505	).
  506copy_profile_field(F, New, Data0, Data) :-
  507	(   Data = Data0.put(F, New.get(F))
  508	->  true
  509	;   del_dict(F, Data0, _, Data)
  510	->  true
  511	;   Data = Data0
  512	).
  513
  514set_nick_name(Data0, Name, Data) :-
  515	Data = Data0.put(_{name:Name, anonymous_name:Name}).
 update_avatar_from_email(+Email, +DataIn, -Data)
Update the avatar after a change of the known email. If the avatar comes from the profile, no action is needed. If Email has a gravatar, use that. Else use the know or a new generated avatar.
  524update_avatar_from_email(_, Data, Data) :-
  525	Data.get(avatar_source) == profile, !.
  526update_avatar_from_email('', Data0, Data) :-
  527	Data0.get(avatar_source) == email, !,
  528	noble_avatar_url(Avatar, []),
  529	Data = Data0.put(_{avatar:Avatar, anonymous_avatar:Avatar,
  530			   avatar_source:generated}).
  531update_avatar_from_email(Email, Data0, Data) :-
  532	email_gravatar(Email, Avatar),
  533	valid_gravatar(Avatar), !,
  534	Data = Data0.put(avatar, Avatar).
  535update_avatar_from_email(_, Data0, Data) :-
  536	(   Avatar = Data0.get(anonymous_avatar)
  537	->  Data = Data0.put(_{avatar:Avatar, avatar_source:client})
  538	;   noble_avatar_url(Avatar, []),
  539	    Data = Data0.put(_{avatar:Avatar, anonymous_avatar:Avatar,
  540			       avatar_source:generated})
  541	).
 anonymise_user_data(TmpUser, Data)
Create anonymous user profile.
  547anonymise_user_data(TmpUser, Data) :-
  548	visitor_data(TmpUser, Old),
  549	(   _{anonymous_name:AName, anonymous_avatar:AAvatar} :< Old
  550	->  Data = _{anonymous_name:AName, anonymous_avatar:AAvatar,
  551		     name:AName, avatar:AAvatar, avatar_source:client}
  552	;   _{anonymous_avatar:AAvatar} :< Old
  553	->  Data = _{anonymous_avatar:AAvatar,
  554		     avatar:AAvatar, avatar_source:client}
  555	;   _{anonymous_name:AName} :< Old
  556	->  noble_avatar_url(Avatar, []),
  557	    Data = _{anonymous_name:AName, anonymous_avatar:Avatar,
  558		     name:AName, avatar:Avatar, avatar_source:generated}
  559	), !.
  560anonymise_user_data(_, Data) :-
  561	noble_avatar_url(Avatar, []),
  562	Data = _{anonymous_avatar:Avatar,
  563		 avatar:Avatar, avatar_source:generated}.
 set_visitor_data(+TmpUser, +Data, +Reason) is det
Update the user data for the session user TmpUser and forward the changes.
  570set_visitor_data(TmpUser, Data, Reason) :-
  571	retractall(visitor_data(TmpUser, _)),
  572	assertz(visitor_data(TmpUser, Data)),
  573	inform_visitor_change(TmpUser, Reason).
 inform_visitor_change(+TmpUser, +Reason) is det
Inform browsers showing TmpUser that the visitor data has changed. The first clause deals with forwarding from HTTP requests, where we have the session and the second from websocket requests where we have the WSID.
  582inform_visitor_change(TmpUser, Reason) :-
  583	http_in_session(Session), !,
  584	public_user_data(TmpUser, Data),
  585	forall(visitor_session(WSID, Session),
  586	       inform_friend_change(WSID, Data, Reason)).
  587inform_visitor_change(TmpUser, Reason) :-
  588	b_getval(wsid, WSID),
  589	public_user_data(TmpUser, Data),
  590	inform_friend_change(WSID, Data, Reason).
  591
  592inform_friend_change(WSID, Data, Reason) :-
  593	Message = json(_{ type:"profile",
  594			  wsid:WSID,
  595			  reason:Reason
  596			}.put(Data)),
  597	hub_send(WSID, Message),
  598	forall(viewing_same_file(WSID, Friend),
  599	       ignore(hub_send(Friend, Message))).
  600
  601viewing_same_file(WSID, Friend) :-
  602	subscription(WSID, gitty, File),
  603	subscription(Friend, gitty, File),
  604	Friend \== WSID.
 subscribe(+WSID, +Channel) is det
  608subscribe(WSID, Channel) :-
  609	subscribe(WSID, Channel, _SubChannel).
  610subscribe(WSID, Channel, SubChannel) :-
  611	(   subscription(WSID, Channel, SubChannel)
  612	->  true
  613	;   assertz(subscription(WSID, Channel, SubChannel))
  614	).
  615
  616unsubscribe(WSID, Channel) :-
  617	unsubscribe(WSID, Channel, _SubChannel).
  618unsubscribe(WSID, Channel, SubChannel) :-
  619	retractall(subscription(WSID, Channel, SubChannel)).
 sync_gazers(+WSID, +Files:list(atom)) is det
A browser signals it has Files open. This happens when a SWISH instance is created as well as when a SWISH instance changes state, such as closing a tab, adding a tab, bringing a tab to the foreground, etc.
  628sync_gazers(WSID, Files0) :-
  629	findall(F, subscription(WSID, gitty, F), Viewing0),
  630	sort(Files0, Files),
  631	sort(Viewing0, Viewing),
  632	(   Files == Viewing
  633	->  true
  634	;   ord_subtract(Files, Viewing, New),
  635	    add_gazing(WSID, New),
  636	    ord_subtract(Viewing, Files, Left),
  637	    del_gazing(WSID, Left)
  638	).
  639
  640add_gazing(_, []) :- !.
  641add_gazing(WSID, Files) :-
  642	inform_me_about_existing_gazers(WSID, Files),
  643	inform_existing_gazers_about_newby(WSID, Files).
  644
  645inform_me_about_existing_gazers(WSID, Files) :-
  646	findall(Gazer, files_gazer(Files, Gazer), Gazers),
  647	ignore(hub_send(WSID, json(_{type:"gazers", gazers:Gazers}))).
  648
  649files_gazer(Files, Gazer) :-
  650	member(File, Files),
  651	subscription(WSID, gitty, File),
  652	visitor_session(WSID, Session),
  653	session_user(Session, UID),
  654	public_user_data(UID, Data),
  655	Gazer = _{file:File, uid:UID, wsid:WSID}.put(Data).
  656
  657inform_existing_gazers_about_newby(WSID, Files) :-
  658	forall(member(File, Files),
  659	       signal_gazer(WSID, File)).
  660
  661signal_gazer(WSID, File) :-
  662	subscribe(WSID, gitty, File),
  663	broadcast_event(opened(File), File, WSID).
  664
  665del_gazing(_, []) :- !.
  666del_gazing(WSID, Files) :-
  667	forall(member(File, Files),
  668	       del_gazing1(WSID, File)).
  669
  670del_gazing1(WSID, File) :-
  671	broadcast_event(closed(File), File, WSID),
  672	unsubscribe(WSID, gitty, File).
 add_user_details(+Message, -Enriched) is det
Add additional information to a message. Message must contain a uid field.
  679add_user_details(Message, Enriched) :-
  680	public_user_data(Message.uid, Data),
  681	Enriched = Message.put(Data).
 public_user_data(+UID, -Public:dict) is det
True when Public provides the information we publically share about UID. This is currently the name and avatar.
  688public_user_data(UID, Public) :-
  689	visitor_data(UID, Data),
  690	(   _{name:Name, avatar:Avatar} :< Data
  691	->  Public = _{name:Name, avatar:Avatar}
  692	;   _{avatar:Avatar} :< Data
  693	->  Public = _{avatar:Avatar}
  694	;   Public = _{}
  695	).
 get_visitor_data(-Data:dict, +Options) is det
Optain data for a new visitor. Options include:
identity(+Identity)
Identity information provided by authenticate/2. Always present.
avatar(+URL)
Avatar provided by the user
nick_name(+Name)
Nick name provided by the user.

Data always contains an avatar key and optionally contains a name and email key. If the avatar is generated there is also a key avatar_generated with the value true.

bug
- This may check for avatar validity, which may take long. Possibly we should do this in a thread.
  716get_visitor_data(Data, Options) :-
  717	option(identity(Identity), Options),
  718	findall(N-V, visitor_property(Identity, Options, N, V), Pairs),
  719	dict_pairs(Data, v, Pairs).
  720
  721visitor_property(Identity, Options, name, Name) :-
  722	(   user_property(Identity, name(Name))
  723	->  true
  724	;   option(nick_name(Name), Options)
  725	).
  726visitor_property(Identity, _, email, Email) :-
  727	user_property(Identity, email(Email)).
  728visitor_property(Identity, Options, Name, Value) :-
  729	(   user_property(Identity, avatar(Avatar))
  730	->  avatar_property(Avatar, profile, Name, Value)
  731	;   user_property(Identity, email(Email)),
  732	    email_gravatar(Email, Avatar),
  733	    valid_gravatar(Avatar)
  734	->  avatar_property(Avatar, email, Name, Value)
  735	;   option(avatar(Avatar), Options)
  736	->  avatar_property(Avatar, client, Name, Value)
  737	;   noble_avatar_url(Avatar, Options),
  738	    avatar_property(Avatar, generated, Name, Value)
  739	).
  740visitor_property(_, Options, anonymous_name, Name) :-
  741	option(nick_name(Name), Options).
  742visitor_property(_, Options, anonymous_avatar, Avatar) :-
  743	option(avatar(Avatar), Options).
  744
  745
  746avatar_property(Avatar, _Source, avatar,        Avatar).
  747avatar_property(_Avatar, Source, avatar_source, Source).
  748
  749
  750		 /*******************************
  751		 *	   NOBLE AVATAR		*
  752		 *******************************/
  753
  754:- http_handler(swish('avatar/'), reply_avatar, [id(avatar), prefix]).
 reply_avatar(+Request)
HTTP handler for Noble Avatar images. Using create_avatar/2 re-creates avatars from the file name, so we can safely discard the avatar file store.

Not really. A new user gets a new avatar and this is based on whether or not the file exists. Probably we should maintain a db of handed out avatars and their last-use time stamp. How to do that? Current swish stats: 400K avatars, 3.2Gb data.

  767reply_avatar(Request) :-
  768	option(path_info(Local), Request),
  769	(   absolute_file_name(noble_avatar(Local), Path,
  770			       [ access(read),
  771				 file_errors(fail)
  772			       ])
  773	->  true
  774	;   create_avatar(Local, Path)
  775	),
  776	http_reply_file(Path, [unsafe(true)], Request).
  777
  778
  779noble_avatar_url(HREF, Options) :-
  780	option(avatar(HREF), Options), !.
  781noble_avatar_url(HREF, _Options) :-
  782	swish_config:config(avatars, noble),
  783	!,
  784	noble_avatar(_Gender, Path, true),
  785	file_base_name(Path, File),
  786	http_absolute_location(swish(avatar/File), HREF, []).
  787noble_avatar_url(HREF, _Options) :-
  788	A is random(0x1FFFFF+1),
  789	http_absolute_location(icons('avatar.svg'), HREF0, []),
  790	format(atom(HREF), '~w#~d', [HREF0, A]).
  791
  792
  793
  794		 /*******************************
  795		 *	   BROADCASTING		*
  796		 *******************************/
 chat_broadcast(+Message) is det
 chat_broadcast(+Message, +Channel) is det
Send Message to all known SWISH clients. Message is a valid JSON object, i.e., a dict or option list.
Arguments:
Channel- is either an atom or a term Channel/SubChannel, where both Channel and SubChannel are atoms.
  807chat_broadcast(Message) :-
  808	debug(chat(broadcast), 'Broadcast: ~p', [Message]),
  809	hub_broadcast(swish_chat, json(Message)).
  810
  811chat_broadcast(Message, Channel/SubChannel) :- !,
  812	must_be(atom, Channel),
  813	must_be(atom, SubChannel),
  814	debug(chat(broadcast), 'Broadcast on ~p: ~p',
  815	      [Channel/SubChannel, Message]),
  816	hub_broadcast(swish_chat, json(Message),
  817		      subscribed(Channel, SubChannel)).
  818chat_broadcast(Message, Channel) :-
  819	must_be(atom, Channel),
  820	debug(chat(broadcast), 'Broadcast on ~p: ~p', [Channel, Message]),
  821	hub_broadcast(swish_chat, json(Message),
  822		      subscribed(Channel)).
  823
  824subscribed(Channel, WSID) :-
  825	subscription(WSID, Channel, _).
  826subscribed(Channel, SubChannel, WSID) :-
  827	subscription(WSID, Channel, SubChannel).
  828subscribed(gitty, SubChannel, WSID) :-
  829	swish_config:config(hangout, SubChannel),
  830	\+ subscription(WSID, gitty, SubChannel).
  831
  832
  833		 /*******************************
  834		 *	     CHAT ROOM		*
  835		 *******************************/
  836
  837create_chat_room :-
  838	current_hub(swish_chat, _), !.
  839create_chat_room :-
  840	with_mutex(swish_chat, create_chat_room_sync).
  841
  842create_chat_room_sync :-
  843	current_hub(swish_chat, _), !.
  844create_chat_room_sync :-
  845	hub_create(swish_chat, Room, _{}),
  846	thread_create(swish_chat(Room), _, [alias(swish_chat)]).
  847
  848swish_chat(Room) :-
  849	(   catch(swish_chat_event(Room), E, chat_exception(E))
  850	->  true
  851	;   print_message(warning, goal_failed(swish_chat_event(Room)))
  852	),
  853	swish_chat(Room).
  854
  855chat_exception('$aborted') :- !.
  856chat_exception(E) :-
  857	print_message(warning, E).
  858
  859swish_chat_event(Room) :-
  860	thread_get_message(Room.queues.event, Message),
  861	(   handle_message(Message, Room)
  862	->  true
  863	;   print_message(warning, goal_failed(handle_message(Message, Room)))
  864	).
 handle_message(+Message, +Room)
Handle incoming messages
  870handle_message(Message, _Room) :-
  871	websocket{opcode:text} :< Message, !,
  872	atom_json_dict(Message.data, JSON, []),
  873	debug(chat(received), 'Received from ~p: ~p', [Message.client, JSON]),
  874	WSID = Message.client,
  875	setup_call_cleanup(
  876	    b_setval(wsid, WSID),
  877	    json_message(JSON, WSID),
  878	    nb_delete(wsid)).
  879handle_message(Message, _Room) :-
  880	hub{joined:WSID} :< Message, !,
  881	debug(chat(visitor), 'Joined: ~p', [WSID]).
  882handle_message(Message, _Room) :-
  883	hub{left:WSID, reason:write(Lost)} :< Message, !,
  884	(   destroy_visitor(WSID)
  885	->  debug(chat(visitor), 'Left ~p due to write error for ~p',
  886		  [WSID, Lost])
  887	;   true
  888	).
  889handle_message(Message, _Room) :-
  890	hub{left:WSID} :< Message, !,
  891	(   destroy_visitor(WSID)
  892	->  debug(chat(visitor), 'Left: ~p', [WSID])
  893	;   true
  894	).
  895handle_message(Message, _Room) :-
  896	websocket{opcode:close, client:WSID} :< Message, !,
  897	debug(chat(visitor), 'Left: ~p', [WSID]),
  898	destroy_visitor(WSID).
  899handle_message(Message, _Room) :-
  900	debug(chat(ignored), 'Ignoring chat message ~p', [Message]).
 json_message(+Message, +WSID) is det
Process a JSON message translated to a dict. The following messages are understood:
  919json_message(Dict, WSID) :-
  920	_{ type: "subscribe",
  921	   channel:ChannelS, sub_channel:SubChannelS} :< Dict, !,
  922	atom_string(Channel, ChannelS),
  923	atom_string(SubChannel, SubChannelS),
  924	subscribe(WSID, Channel, SubChannel).
  925json_message(Dict, WSID) :-
  926	_{type: "subscribe", channel:ChannelS} :< Dict, !,
  927	atom_string(Channel, ChannelS),
  928	subscribe(WSID, Channel).
  929json_message(Dict, WSID) :-
  930	_{ type: "unsubscribe",
  931	   channel:ChannelS, sub_channel:SubChannelS} :< Dict, !,
  932	atom_string(Channel, ChannelS),
  933	atom_string(SubChannel, SubChannelS),
  934	unsubscribe(WSID, Channel, SubChannel).
  935json_message(Dict, WSID) :-
  936	_{type: "unsubscribe", channel:ChannelS} :< Dict, !,
  937	atom_string(Channel, ChannelS),
  938	unsubscribe(WSID, Channel).
  939json_message(Dict, WSID) :-
  940	_{type: "unload"} :< Dict, !,	% clean close/reload
  941	sync_gazers(WSID, []),
  942	assertz(visitor_status(WSID, unload)).
  943json_message(Dict, WSID) :-
  944	_{type: "has-open-files", files:FileDicts} :< Dict, !,
  945	maplist(dict_file_name, FileDicts, Files),
  946	sync_gazers(WSID, Files).
  947json_message(Dict, WSID) :-
  948	_{type: "reloaded", file:FileS, commit:Hash} :< Dict, !,
  949	atom_string(File, FileS),
  950	event_html(reloaded(File), HTML),
  951	Message = _{ type:notify,
  952		     wsid:WSID,
  953		     html:HTML,
  954		     event:reloaded,
  955		     argv:[File,Hash]
  956		   },
  957	chat_broadcast(Message, gitty/File).
  958json_message(Dict, WSID) :-
  959	_{type: "set-nick-name", name:Name} :< Dict, !,
  960	wsid_visitor(WSID, Visitor),
  961	update_visitor_data(Visitor, _{name:Name}, 'set-nick-name').
  962json_message(Dict, WSID) :-
  963	_{type: "chat-message", docid:DocID} :< Dict, !,
  964	chat_add_user_id(WSID, Dict, Message),
  965	(   forbidden(Message, DocID, Why)
  966	->  hub_send(WSID, json(json{type:forbidden,
  967				     action:chat_post,
  968				     about:DocID,
  969				     message:Why
  970				    }))
  971	;   chat_relay(Message)
  972	).
  973json_message(Dict, _WSID) :-
  974	debug(chat(ignored), 'Ignoring JSON message ~p', [Dict]).
  975
  976dict_file_name(Dict, File) :-
  977	atom_string(File, Dict.get(file)).
 forbidden(+Message, +DocID, -Why) is semidet
True if the chat Message about DocID must be forbidden, in which case Why is unified with a string indicating the reason. Currently:
To be done
- Call authorized/2 with all proper identity information.
  990forbidden(Message, DocID, Why) :-
  991	\+ swish_config:config(chat_spam_protection, false),
  992	\+ ws_authorized(chat(post(Message, DocID)), Message.user), !,
  993	Why = "Due to frequent spamming we were forced to limit \c
  994	       posting chat messages to users who are logged in.".
  995forbidden(Message, _DocID, Why) :-
  996	Text = Message.get(text),
  997	string_length(Text, Len),
  998	Len > 500,
  999	Why = "Chat messages are limited to 500 characters".
 1000forbidden(Message, _DocID, Why) :-
 1001	Payloads = Message.get(payload),
 1002	member(Payload, Payloads),
 1003	large_payload(Payload, Why), !.
 1004forbidden(Message, _DocID, Why) :-
 1005	\+ swish_config:config(chat_spam_protection, false),
 1006	eval_content(Message.get(text), _WC, Score),
 1007	user_score(Message, Score, Cummulative, _Count),
 1008	Score*2 + Cummulative < 0,
 1009	!,
 1010	Why = "Chat messages must be in English and avoid offensive language".
 1011
 1012large_payload(Payload, Why) :-
 1013	Selections = Payload.get(selection),
 1014	member(Selection, Selections),
 1015	(   string_length(Selection.get(string), SelLen), SelLen > 500
 1016	;   string_length(Selection.get(context), SelLen), SelLen > 500
 1017	), !,
 1018	Why = "Selection too long (max. 500 characters)".
 1019large_payload(Payload, Why) :-
 1020	string_length(Payload.get(query), QLen), QLen > 1000, !,
 1021	Why = "Query too long (max. 1000 characters)".
 1022
 1023user_score(Message, MsgScore, Cummulative, Count) :-
 1024	Profile	= Message.get(user).get(profile_id), !,
 1025	block(Profile, MsgScore, Cummulative, Count).
 1026user_score(_, _, 0, 1).
 block(+User, +Score, -Cummulative, -Count)
Keep a count and cummulative score for a user.
 1032:- dynamic
 1033	blocked/4. 1034
 1035block(User, Score, Cummulative, Count) :-
 1036	blocked(User, Score0, Count0, Time), !,
 1037	get_time(Now),
 1038	Cummulative = Score0*(0.5**((Now-Time)/600)) + Score,
 1039	Count is Count0 + 1,
 1040	asserta(blocked(User, Cummulative, Count, Now)),
 1041	retractall(blocked(User, Score0, Count0, Time)).
 1042block(User, Score, Score, 1) :-
 1043	get_time(Now),
 1044	asserta(blocked(User, Score, 1, Now)).
 1045
 1046
 1047		 /*******************************
 1048		 *	   CHAT MESSAGES	*
 1049		 *******************************/
 chat_add_user_id(+WSID, +Message0, -Message) is det
Decorate a message with the user credentials.
 1055chat_add_user_id(WSID, Dict, Message) :-
 1056	visitor_session(WSID, Session, _Token),
 1057	session_user(Session, Visitor),
 1058	visitor_data(Visitor, UserData),
 1059	User0 = u{avatar:UserData.avatar,
 1060		  wsid:WSID
 1061		 },
 1062	(   Name = UserData.get(name)
 1063	->  User1 = User0.put(name, Name)
 1064	;   User1 = User0
 1065	),
 1066	(   http_current_session(Session, profile_id(ProfileID))
 1067	->  User = User1.put(profile_id, ProfileID)
 1068	;   User = User1
 1069	),
 1070	Message = Dict.put(user, User).
 chat_about(+DocID, +Message) is det
Distribute a chat message about DocID.
 1077chat_about(DocID, Message) :-
 1078	chat_relay(Message.put(docid, DocID)).
 chat_relay(+Message) is det
Store and relay a chat message.
 1084chat_relay(Message) :-
 1085	chat_enrich(Message, Message1),
 1086	chat_send(Message1).
 chat_enrich(+Message0, -Message) is det
Add time and identifier to the chat message.
 1092chat_enrich(Message0, Message) :-
 1093	get_time(Now),
 1094	uuid(ID),
 1095	Message = Message0.put(_{time:Now, id:ID}).
 chat_send(+Message)
Relay the chat message Message. If the message has a volatile property it is broadcasted, but not stored.
 1102chat_send(Message) :-
 1103	atom_concat("gitty:", File, Message.docid),
 1104	broadcast(swish(chat(Message))),
 1105	(   Message.get(volatile) == true
 1106	->  true
 1107	;   chat_store(Message)
 1108	),
 1109	chat_broadcast(Message, gitty/File).
 1110
 1111
 1112		 /*******************************
 1113		 *	      EVENTS		*
 1114		 *******************************/
 1115
 1116:- unlisten(swish(_)),
 1117   listen(swish(Event), chat_event(Event)).
 chat_event(+Event) is semidet
Event happened inside SWISH. Currently triggered events:
updated(+File, +From, +To)
File was updated from hash From to hash To.
profile(+ProfileID)
Session was associated with user with profile ProfileID
logout(+ProfileID)
User logged out. If the login was based on HTTP authentication ProfileID equals http.
 1131chat_event(Event) :-
 1132	broadcast_event(Event),
 1133	http_session_id(Session),
 1134	debug(event, 'Event: ~p, session ~q', [Event, Session]),
 1135	event_file(Event, File), !,
 1136	(   visitor_session(WSID, Session),
 1137	    subscription(WSID, gitty, File)
 1138	->  true
 1139	;   visitor_session(WSID, Session)
 1140	->  true
 1141	;   WSID = undefined
 1142	),
 1143	session_broadcast_event(Event, File, Session, WSID).
 1144chat_event(logout(_ProfileID)) :- !,
 1145	http_session_id(Session),
 1146	session_user(Session, User),
 1147	update_visitor_data(User, _, logout).
 1148chat_event(visitor_count(Count)) :-		% request
 1149	visitor_count(Count).
 1150
 1151:- if(current_predicate(current_profile/2)). 1152
 1153chat_event(profile(ProfileID)) :- !,
 1154	current_profile(ProfileID, Profile),
 1155	http_session_id(Session),
 1156	session_user(Session, User),
 1157	update_visitor_data(User, Profile, login).
 propagate_profile_change(+ProfileID, +Attribute, +Value)
Trap external changes to the profile.
 1163:- listen(user_profile(modified(ProfileID, Name, _Old, New)),
 1164          propagate_profile_change(ProfileID, Name, New)). 1165
 1166propagate_profile_change(ProfileID, _, _) :-
 1167	http_current_session(Session, profile_id(ProfileID)),
 1168	session_user(Session, User),
 1169	current_profile(ProfileID, Profile),
 1170	update_visitor_data(User, Profile, 'profile-edit').
 1171
 1172:- endif.
 broadcast_event(+Event) is semidet
If true, broadcast this event.
 1178broadcast_event(updated(_File, _From, _To)).
 broadcast_event(+Event, +File, +WSID) is det
Event happened that is related to File in WSID. Broadcast it to subscribed users as a notification. Always succeeds, also if the message cannot be delivered.
To be done
- Extend the structure to allow other browsers to act.
 1189broadcast_event(Event, File, WSID) :-
 1190	visitor_session(WSID, Session),
 1191	session_broadcast_event(Event, File, Session, WSID), !.
 1192broadcast_event(_, _, _).
 1193
 1194session_broadcast_event(Event, File, Session, WSID) :-
 1195	session_user(Session, UID),
 1196	event_html(Event, HTML),
 1197	Event =.. [EventName|Argv],
 1198	Message0 = _{ type:notify,
 1199		      uid:UID,
 1200		      html:HTML,
 1201		      event:EventName,
 1202		      event_argv:Argv,
 1203		      wsid:WSID
 1204		    },
 1205	add_user_details(Message0, Message),
 1206	chat_broadcast(Message, gitty/File).
Describe an event as an HTML message to be displayed in the client's notification area.
 1213event_html(Event, HTML) :-
 1214	(   phrase(event_message(Event), Tokens)
 1215	->  true
 1216	;   phrase(html('Unknown-event: ~p'-[Event]), Tokens)
 1217	),
 1218	delete(Tokens, nl(_), SingleLine),
 1219	with_output_to(string(HTML), print_html(SingleLine)).
 1220
 1221event_message(created(File)) -->
 1222	html([ 'Created ', \file(File) ]).
 1223event_message(reloaded(File)) -->
 1224	html([ 'Reloaded ', \file(File) ]).
 1225event_message(updated(File, _From, _To)) -->
 1226	html([ 'Saved ', \file(File) ]).
 1227event_message(deleted(File, _From, _To)) -->
 1228	html([ 'Deleted ', \file(File) ]).
 1229event_message(closed(File)) -->
 1230	html([ 'Closed ', \file(File) ]).
 1231event_message(opened(File)) -->
 1232	html([ 'Opened ', \file(File) ]).
 1233event_message(download(File)) -->
 1234	html([ 'Opened ', \file(File) ]).
 1235event_message(download(Store, FileOrHash, _Format)) -->
 1236	{ event_file(download(Store, FileOrHash), File)
 1237	},
 1238	html([ 'Opened ', \file(File) ]).
 1239
 1240file(File) -->
 1241	html(a(href('/p/'+File), File)).
 event_file(+Event, -File) is semidet
True when Event is associated with File.
 1247event_file(created(File, _Commit), File).
 1248event_file(updated(File, _Commit), File).
 1249event_file(deleted(File, _Commit), File).
 1250event_file(download(Store, FileOrHash, _Format), File) :-
 1251	(   is_gitty_hash(FileOrHash)
 1252	->  gitty_commit(Store, FileOrHash, Meta),
 1253	    File = Meta.name
 1254	;   File = FileOrHash
 1255	).
 1256
 1257
 1258		 /*******************************
 1259		 *	   NOTIFICATION		*
 1260		 *******************************/
 chat_to_profile(ProfileID, :HTML) is det
Send a HTML notification to users logged in using ProfileID.
 1266chat_to_profile(ProfileID, HTML) :-
 1267	(   http_current_session(Session, profile_id(ProfileID)),
 1268	    visitor_session(WSID, Session),
 1269	    html_string(HTML, String),
 1270	    hub_send(WSID, json(_{ wsid:WSID,
 1271				   type:notify,
 1272				   html:String
 1273				 })),
 1274	    debug(notify(chat), 'Notify to ~p: ~p', [ProfileID, String]),
 1275	    fail
 1276	;   true
 1277	).
 1278
 1279html_string(HTML, String) :-
 1280	phrase(html(HTML), Tokens),
 1281	delete(Tokens, nl(_), SingleLine),
 1282	with_output_to(string(String), print_html(SingleLine)).
 1283
 1284
 1285
 1286
 1287		 /*******************************
 1288		 *	       UI		*
 1289		 *******************************/
 notifications(+Options)//
The chat element is added to the navbar and managed by web/js/chat.js
 1296notifications(_Options) -->
 1297	{ swish_config:config(chat, true) }, !,
 1298	html(div(class(chat),
 1299		 [ div(class('chat-users'),
 1300		       ul([ class([nav, 'navbar-nav', 'pull-right']),
 1301			    id(chat)
 1302			  ], [])),
 1303		   div(class('user-count'),
 1304		       [ span(id('user-count'), '?'),
 1305			 ' users online'
 1306		       ])
 1307		 ])).
 1308notifications(_Options) -->
 1309	[].
 broadcast_bell(+Options)//
Adds a bell to indicate central chat messages
 1315broadcast_bell(_Options) -->
 1316	{ swish_config:config(chat, true),
 1317	  swish_config:config(hangout, Hangout),
 1318	  atom_concat('gitty:', Hangout, HangoutID)
 1319	}, !,
 1320	html([ a([ class(['dropdown-toggle', 'broadcast-bell']),
 1321		   'data-toggle'(dropdown)
 1322		 ],
 1323		 [ span([ id('broadcast-bell'),
 1324			  'data-document'(HangoutID)
 1325			], []),
 1326		   b(class(caret), [])
 1327		 ]),
 1328	       ul([ class(['dropdown-menu', 'pull-right']),
 1329		    id('chat-menu')
 1330		  ],
 1331		  [ li(a('data-action'('chat-shared'),
 1332			 'Open hangout')),
 1333		    li(a('data-action'('chat-about-file'),
 1334			 'Open chat for current file'))
 1335		  ])
 1336	     ]).
 1337broadcast_bell(_Options) -->
 1338	[].
 1339
 1340
 1341		 /*******************************
 1342		 *	      MESSAGES		*
 1343		 *******************************/
 1344
 1345:- multifile
 1346	prolog:message_context//1. 1347
 1348prolog:message_context(websocket(reconnect(Passed, Score))) -->
 1349	[ 'WebSocket: too frequent reconnect requests (~1f sec; score = ~1f)'-
 1350	  [Passed, Score] ]