1# $Id$
2# Support for ignoring occupant activity in MUC rooms.
3#
4# A note on runtime ruleset format:
5# * A hash is used to hold ignore rules at runtime; each key
6#   uniquely refers to its related "xlib, room, occupant,
7#   message type" tuple; the existence of a key is used to
8#   determine the fact of some type of a room occupant messages
9#   being ignored.
10# * The format of the ruleset keys is as follows:
11#   SESSION_JID NUL ROOM_JID/OCCUPANT NUL TYPE
12#   where:
13#   * NUL means character with code 0 (\u0000 in Tcl lingo).
14#     It is used since ASCII NUL is prohibited in JIDs;
15#   * SESSION_JID is the bare JID of a particular connection of
16#     the Tkabber user (support for multiaccounting);
17#   * ROOM_JID is the room bare JID;
18#   * OCCUPANT is either an occupant's room nick OR her full
19#     bare JID, if it's available;
20#   * TYPE is either "chat" or "groupchat", literally, which determines
21#     the type of messages to ignore.
22
23namespace eval mucignore {
24    variable options
25
26    variable ignored
27
28    variable tid 0
29    variable tags
30
31    variable menustate
32
33    # Cutsomize section:
34
35    custom::defvar stored_rules {} \
36	"Stored MUC ignore rules" \
37	-group Hidden \
38	-type string
39
40    hook::add post_custom_restore [namespace current]::restore_rules
41
42    custom::defgroup {MUC Ignoring} \
43	[::msgcat::mc "Ignoring groupchat and chat messages\
44		       from selected occupants of multi-user conference\
45		       rooms."] \
46	-group Privacy \
47	-group Chat
48
49    custom::defvar options(transient_rules) 0 \
50	[::msgcat::mc "When set, all changes to the ignore rules are\
51		       applied only until Tkabber is closed\;\
52		       they are not saved and thus will be not restored at\
53		       the next run."] \
54	-group {MUC Ignoring} \
55	-type boolean
56
57    # Event handlers:
58
59    # Handlers for creating various menus:
60    hook::add chat_create_conference_menu_hook \
61	[namespace current]::setup_muc_menu
62    hook::add chat_create_user_menu_hook \
63	[namespace current]::setup_private_muc_chat_menu
64    hook::add roster_create_groupchat_user_menu_hook \
65	[namespace current]::setup_occupant_menu
66    hook::add finload_hook \
67	[namespace current]::on_init
68
69    # Block private MUC messages:
70    hook::add process_message_hook \
71	[namespace current]::process_message
72
73    # Weed out MUC room messages upon entering a room:
74    hook::add open_chat_post_hook \
75	[namespace current]::sanitize_muc_display
76
77    # Catch presence of ignored users.
78    # NOTE: the order of this handler must be higher than
79    # that of ::muc::process_presence (which is the default of 50)
80    # since that handler extracts and stores the room occupant's
81    # real JID in the non-anonymous rooms.
82    hook::add client_presence_hook \
83	[namespace current]::catch_junkie_presence 55
84
85    # Adjust ignore rules on nick renames.
86    # NOTE: this hook must be run earlier than client_presence_hook.
87    hook::add room_nickname_changed_hook \
88	[namespace current]::trace_room_nick_change
89
90    hook::add generate_completions_hook \
91	[namespace current]::add_completions
92
93    hook::add chat_send_message_hook \
94	[namespace current]::handle_commands
95}
96
97# "Ignore tags" are used to mark whole messages posted in the room
98# by an ignored occupant. Their names are autogenerated and unique
99# throughout one Tkabber run. Each tag is bound to one particular
100# "room JID" of the ignored occupant. Ignore tags may be rebound
101# to another room JID when these change (on nickname changes).
102
103# Creates the ignore tag for a particular "room JID".
104# If matching tag exists, this proc does nothing, silently.
105# This provides for ignore tag "persistence".
106proc mucignore::ignore_tag_create {roomjid} {
107    variable tid
108    variable tags
109
110    if {[info exists tags($roomjid)]} return
111
112    set tags($roomjid) IGNORED-$tid
113    incr tid
114}
115
116proc mucignore::ignore_tag_get {roomjid} {
117    variable tags
118
119    set tags($roomjid)
120}
121
122proc mucignore::ignore_tag_rebind {from to} {
123    variable tags
124
125    set tags($to) $tags($from)
126    unset tags($from)
127}
128
129proc mucignore::ignore_tag_forget {roomjid} {
130    variable tags
131
132    unset tags($roomjid)
133}
134
135# Returns bare JID of the session identified by $xlib
136proc mucignore::session_bare_jid {xlib} {
137    ::::xmpp::jid::stripResource [connection_jid $xlib]
138}
139
140# Tries to get the real bare JID of the room occupant identified
141# by the $room_occupant_jid; returns that JID if it's available,
142# empty string otherwise.
143proc mucignore::get_real_bare_jid {xlib room_occupant_jid} {
144    set real_jid [::muc::get_real_jid $xlib $room_occupant_jid]
145    if {$real_jid != {}} {
146	return [::::xmpp::jid::stripResource $real_jid]
147    } else {
148	return {}
149    }
150}
151
152# Creates an ignore rule suitable for using as a key to a hash of rules.
153# Expects:
154# * entity -- session's bare JID;
155# * jid -- JID to ignore ("room/nick" or "room/real_bare_jid");
156# * type -- type of chat to ignore ("groupchat" or "chat").
157# These parts are joined using the NUL character (since its appearance
158# is prohibited in any part of a JID) and so the rule can be reliably
159# split back into parts.
160# See also: [split_rule].
161proc mucignore::mkrulekey {entity jid type} {
162    join [list $entity $jid $type] \u0000
163}
164
165# Creates an ignore rule suitable for using as a key to a hash of rules.
166# The $xlib parameter is converted to the session's bare JID first.
167# It's just a convenient wrapper around [mkrulekey].
168proc mucignore::mkrule {xlib jid type} {
169    mkrulekey [session_bare_jid $xlib] $jid $type
170}
171
172# Splits given rule into the list of [entity jid type], where:
173# * entity -- is a bare JID of the user's session;
174# * jid -- is a JID to be ignored (usually a full room JID);
175# * type -- one of: "groupchat" or "chat", designating the type of messages
176#   originating from jid to be ignored.
177# This proc reverses what [mkrulekey] does.
178proc mucignore::split_rule {rule} {
179    split $rule \u0000
180}
181
182proc mucignore::setup_muc_menu {m xlib jid} {
183    # TODO
184    return
185    $m add command \
186	-label [::msgcat::mc "Edit MUC ignore rules"] \
187	-command [list [namespace current]::editor::open $xlib $jid]
188}
189
190proc mucignore::on_init {} {
191    # TODO
192    return
193    set menu [.mainframe getmenu plugins]
194    $menu add command -label [::msgcat::mc "Edit MUC ignore rules"] \
195        -command [list [namespace current]::editor::open {} {}]
196}
197
198proc mucignore::setup_private_muc_chat_menu {m xlib jid} {
199    set room [::::xmpp::jid::stripResource $jid]
200    if {![::chat::is_groupchat [::chat::chatid $xlib $room]]} return
201
202    setup_occupant_menu $m $xlib $jid
203}
204
205# Prepares two global variables mirroring the current state of
206# ignoring for the room occupant on which groupchat roster nick
207# the menu is being created. They are used to represent
208# ignore state checkbutton menu entries.
209proc mucignore::setup_occupant_menu {m xlib jid} {
210    variable ignored
211    variable menustate
212
213    set our_nick [::get_our_groupchat_nick [
214	::chat::chatid $xlib [
215	    ::::xmpp::jid::stripResource $jid]]]
216    set nick [::chat::get_nick $xlib $jid groupchat]
217
218    if {$nick == $our_nick} {
219	# don't allow to ignore ourselves
220	set state disabled
221    } else {
222	set state normal
223    }
224
225    foreach type {groupchat chat} {
226	set menustate($xlib,$jid,$type) [
227	    info exists ignored([mkrule $xlib $jid $type])]
228    }
229
230    set sm [menu $m.mucignore -tearoff 0]
231    $m add cascade -menu $sm \
232	-state $state \
233	-label [::msgcat::mc "Ignore"]
234
235    $sm add checkbutton -label [::msgcat::mc "Ignore groupchat messages"] \
236	-variable [namespace current]::menustate($xlib,$jid,groupchat) \
237	-command [list [namespace current]::menu_toggle_ignoring \
238		       $xlib $jid groupchat]
239    $sm add checkbutton -label [::msgcat::mc "Ignore chat messages"] \
240	-variable [namespace current]::menustate($xlib,$jid,chat) \
241	-command [list [namespace current]::menu_toggle_ignoring \
242		       $xlib $jid chat]
243
244    bind $m <Destroy> +[double% [list \
245	[namespace current]::menu_cleanup_state $xlib $jid]]
246}
247
248proc mucignore::menu_toggle_ignoring {xlib jid type} {
249    variable menustate
250
251    if {$menustate($xlib,$jid,$type)} {
252	occupant_ignore $xlib $jid $type
253    } else {
254	occupant_attend $xlib $jid $type
255    }
256}
257
258proc mucignore::menu_cleanup_state {xlib jid} {
259    variable menustate
260
261    array unset menustate $xlib,$jid,*
262}
263
264# Ignores specified room occupant:
265# * Creates an ignore rule for her;
266# * Creates an ignore tag, if needed;
267# * Hides messages tagged with that tag, if any;
268# * Builds and saves current ruleset to the Customize db.
269proc mucignore::occupant_ignore {xlib jid args} {
270    variable options
271    variable ignored
272
273    foreach type $args {
274	set ignored([mkrule $xlib $jid $type]) true
275
276	if {$type == "groupchat"} {
277	    ignore_tag_create $jid
278	    room_weed_messages $xlib $jid true
279	}
280    }
281
282    if {!$options(transient_rules)} {
283	store_rules $xlib
284    }
285}
286
287# Un-ignores specified room occupant:
288# * Removes her ignore rules;
289# * Shows any hidden messages from her;
290# * Ignore tag is NOT removed to provide for "quick picking"
291#   into what the ignored occupant have had written so far --
292#   when she is ignored again, all her messages tagged with
293#   the appropriate ignore tag are again hidden.
294# * Builds and saves current ruleset to the Customize db.
295proc mucignore::occupant_attend {xlib jid args} {
296    variable options
297    variable ignored
298
299    foreach type $args {
300	set rule [mkrule $xlib $jid $type]
301	if {[info exists ignored($rule)]} {
302	    unset ignored($rule)
303	    if {$type == "groupchat"} {
304		room_weed_messages $xlib $jid false
305		# we don't use [ignore_tag_forget] here
306		# so when we switch ignoring back on,
307		# all already marked messagess will be weed out
308	    }
309	}
310    }
311
312    if {!$options(transient_rules)} {
313	store_rules $xlib
314    }
315}
316
317# Hides or shows messages tagged as ignored for the $jid, if any.
318proc mucignore::room_weed_messages {xlib jid hide} {
319    set room [::::xmpp::jid::stripResource $jid]
320    set cw [::chat::chat_win [::chat::chatid $xlib $room]]
321
322    $cw tag configure [ignore_tag_get $jid] -elide $hide
323}
324
325# This handler blocks further processing of the private room message
326# if its sender is blacklisted.
327# If the message is groupchat and its sender is blacklisted, it sets
328# the appropriate message property so that other message handlers
329# could treat such message in some special way.
330proc mucignore::process_message {xlib from id type args} {
331    variable ignored
332
333    if {$type == "chat" && \
334	[info exists ignored([mkrule $xlib $from chat])]} {
335	return stop
336    }
337}
338
339proc mucignore::is_ignored {xlib jid type} {
340    variable ignored
341
342    if {[info exists ignored([mkrule $xlib $jid $type])]} {
343	return [ignore_tag_get $jid]
344    } else {
345	return ""
346    }
347}
348
349# This handler is being run after opening the chat window.
350# It searches the ignore rules for JIDs matching the JID of the room,
351# extracts them from the rules and weeds out their messages from
352# the room display (chatlog).
353# NOTE that it gets executed before any presences arrive from the room
354# occupants, so the whole idea is to weed out messages with known (ignored)
355# nicks.
356proc mucignore::sanitize_muc_display {chatid type} {
357    variable ignored
358
359    if {$type != "groupchat"} return
360
361    set xlib [::chat::get_xlib $chatid]
362    set jid [::chat::get_jid $chatid]
363
364    foreach rule [array names ignored [mkrule $xlib $jid/* groupchat]] {
365	set junkie [lindex [split_rule $rule] 1]
366	# TODO handle "real JIDs" case...
367	ignore_tag_create $junkie
368	room_weed_messages $xlib $junkie true
369    }
370}
371
372# This handler is being run after the room_nickname_changed_hook
373# (which takes care of renaming the ignore list entries).
374# This proc serves two purposes:
375# * It converts rules from real JIDs and room JIDs and back
376#   so that room JIDs are used for rule matching and real JIDs
377#   are stored, if they are available, between sessions.
378# * It arranges for chat log display to be prepared to weed out
379#   messages from ignored JIDs.
380
381# TODO why does real JID is available when this handler is run with
382#      $type == "unavailable". memory leak in chats.tcl?
383# TODO use chat_user_enter/chat_user_exit instead?
384proc mucignore::catch_junkie_presence {xlib from pres args} {
385    variable options
386    variable ignored
387
388    set room [::::xmpp::jid::stripResource $from]
389    set rjid [get_real_bare_jid $xlib $from]
390
391    if {$pres == "available"} {
392	debugmsg mucignore "avail: $from; real jid: $rjid"
393	foreach type {groupchat chat} {
394	    if {$rjid != {} && \
395		[info exists ignored([mkrule $xlib $room/$rjid $type])]} {
396		rename_rule_jid $xlib $room/$rjid $from $type
397	    }
398	}
399
400	if {[info exists ignored([mkrule $xlib $from groupchat])]} {
401	    ignore_tag_create $from
402	    room_weed_messages $xlib $from true
403	}
404    } elseif {$pres == "unavailable"} {
405	debugmsg mucignore "unavail: $from; real jid: $rjid"
406	if {[info exists ignored([mkrule $xlib $from groupchat])]} {
407	    ignore_tag_forget $from
408	}
409
410	foreach type {groupchat chat} {
411	    if {$rjid != {} && \
412		[info exists ignored([mkrule $xlib $from $type])]} {
413		rename_rule_jid $xlib $from $room/$rjid $type
414	    }
415	}
416    }
417}
418
419proc mucignore::trace_room_nick_change {chatid oldnick newnick} {
420    variable ignored
421
422    set xlib [chat::get_xlib $chatid]
423    set room [chat::get_jid $chatid]
424    foreach type {groupchat chat} {
425	if {[info exists ignored([mkrule $xlib $room/$oldnick $type])]} {
426	    rename_rule_jid $xlib $room/$oldnick $room/$newnick $type
427
428	    if {$type == "groupchat"} {
429		ignore_tag_rebind $room/$oldnick $room/$newnick
430	    }
431	}
432    }
433}
434
435proc mucignore::rename_rule_jid {xlib from to type} {
436    variable ignored
437
438    set oldrule [mkrule $xlib $from $type]
439    set newrule [mkrule $xlib $to $type]
440
441    set ignored($newrule) [set ignored($oldrule)]
442    unset ignored($oldrule)
443
444    debugmsg mucignore "rule renamed:\
445	[string map {\u0000 |} $oldrule]\
446	[string map {\u0000 |} $newrule]"
447}
448
449proc mucignore::explode_room_jid {xlib room_occupant_jid vroom voccupant} {
450    upvar 1 $vroom room $voccupant occupant
451
452    set room [::::xmpp::jid::stripResource $room_occupant_jid]
453
454    set occupant [get_real_bare_jid $xlib $room_occupant_jid]
455    if {$occupant == {}} {
456	set occupant [::::xmpp::jid::resource $room_occupant_jid]
457    }
458}
459
460# Parses the runtime hash of ignore rules, makes up the hierarchical list
461# (a tree) of ignore rules, resolving the room JIDs to real JIDs,
462# if possible, then saves the list to the corresponding Customize variable.
463# The list has the form:
464# * session_bare_jid_1
465#   * room_bare_jid_1
466#     * occupant_1 (nick or real_jid)
467#       * "groupchat" or "chat" or both
468# ...and so on
469proc mucignore::store_rules {xlib} {
470    variable ignored
471    variable stored_rules
472
473    array set entities {}
474
475    foreach rule [array names ignored] {
476	lassign [split_rule $rule] entity jid type
477
478	explode_room_jid $xlib $jid room occupant
479
480	set entities($entity) 1
481
482	set rooms rooms_$entity
483	if {![info exists $rooms]} {
484	    array set $rooms {}
485	}
486	set [set rooms]($room) 1
487
488	set occupants occupants_$entity$room
489	if {![info exists $occupants]} {
490	    array set $occupants {}
491	}
492
493	lappend [set occupants]($occupant) $type
494    }
495
496    set LE {}
497    foreach entity [array names entities] {
498	set LR {}
499	foreach room [array names rooms_$entity] {
500	    set LO {}
501	    set occupants occupants_$entity$room
502	    foreach occupant [array names $occupants] {
503		lappend LO $occupant [set [set occupants]($occupant)]
504	    }
505
506	    lappend LR $room $LO
507	}
508
509	lappend LE $entity $LR
510    }
511
512    set stored_rules [list 1.0 $LE] ;# also record "ruleset syntax" version
513
514    debugmsg mucignore "STORED: $LE"
515}
516
517proc mucignore::restore_rules {args} {
518    variable ignored
519    variable stored_rules
520
521    array set ignored {}
522
523    set failed [catch {
524	lassign $stored_rules version ruleset
525	array set entities $ruleset
526	foreach entity [array names entities] {
527	    array set rooms $entities($entity)
528	    foreach room [array names rooms] {
529		array set occupants $rooms($room)
530		foreach occupant [array names occupants] {
531		    foreach type $occupants($occupant) {
532			set ignored([mkrulekey $entity $room/$occupant $type]) true
533		    }
534		}
535		array unset occupants
536	    }
537	    array unset rooms
538	}
539    } err]
540
541    if {$failed} {
542	global errorInfo
543	set bt $errorInfo
544
545	set stored_rules {}
546
547	after idle [list error \
548	    [::msgcat::mc "Error loading MUC ignore rules, purged."] $bt]
549    }
550
551    debugmsg mucignore "RESTORED: [string map {\u0000 |} [array names ignored]]"
552}
553
554proc mucignore::add_completions {chatid compsvar wordstart line} {
555    upvar 0 $compsvar comps
556    lappend comps "/ignore " "/unignore "
557}
558
559proc mucignore::handle_commands {chatid user body type} {
560    set xlib [chat::get_xlib $chatid]
561
562    if {[string equal -length 8 $body "/ignore "]} {
563	set nick [string range $body 8 end]
564	set jid [chat::get_jid $chatid]/$nick
565	occupant_ignore $xlib $jid $type
566
567	set msg [::msgcat::mc "%s was ignored" $nick]
568	::chat::add_message $chatid "" chat $msg {}
569
570	return stop
571    }
572    if {[string equal -length 10 $body "/unignore "]} {
573	set nick [string range $body 10 end]
574	set jid [chat::get_jid $chatid]/$nick
575	occupant_attend $xlib $jid $type
576
577	set msg [::msgcat::mc "%s was unignored" $nick]
578	::chat::add_message $chatid "" chat $msg {}
579
580	return stop
581    }
582}
583
584########################################################################
585# MUC Ignore ruleset editor
586########################################################################
587
588namespace eval mucignore::editor {}
589
590# ...
591# NOTE that both $xlib and $jid may be empty at the time of invocation.
592proc mucignore::editor::open {xlib jid} {
593    set w .mucignore_rules_editor
594    if {[winfo exists $w]} {
595	return
596    }
597
598    add_win $w -title [::msgcat::mc "MUC Ignore Rules"] \
599	-tabtitle [::msgcat::mc "MUC Ignore"] \
600	-class MUCIgnoreRulesetEditor \
601	-raise 1
602	#-raisecmd "focus [list $w.input]"
603
604    set sw [ScrolledWindow $w.sw -auto both]
605    set t [Tree $w.tree -background [$w cget -background]]
606    $sw setwidget $t
607
608    bind $sw <Destroy> [list [namespace current]::cleanup [double% $w]]
609
610    pack $sw -fill both -expand true
611
612    # NOTE that BWidget Tree doesn't aceept keyboard bindings.
613
614    $t bindText <Double-ButtonPress-1> [list %W toggle]
615    bind $w <KeyPress-Return> [list [namespace current]::tree_toggle [double% $t]]
616
617    bind $w <KeyPress-F2> [list [namespace current]::tree_edit_item [double% $t]]
618    bind $w <Any-KeyPress-Insert> [list [namespace current]::tree_insert_item [double% $t]]
619    bind $w <Any-KeyPress-Delete> [list [namespace current]::tree_insert_item [double% $t]]
620}
621
622proc mucignore::editor::cleanup {w} {
623    # TODO do appropriate cleanup...
624}
625
626proc mucignore::editor::tree_toggle {t} {
627    set node [lindex [$t selection get] 0]
628    if {$node != {}} {
629	$t toggle $node
630    }
631}
632
633proc mucignore::editor::tree_edit_item {t} {
634    set node [lindex [$t selection get] 0]
635    if {$node == {}} return
636
637    set text [$t itemcget $node -text]
638
639    $t edit $node $text
640}
641
642proc mucignore::editor::tree_insert_item {t} {
643    set parent [lindex [$t selection get] 0]
644
645    if {$parent == {}} {
646	set parent root
647    }
648
649    # TODO implement
650    #add_nodes $t $parent {New {}}
651}
652
653# vim:ts=8:sw=4:sts=4:noet
654