1# -----------------------------------------------------------------------------
2# $Id: Rejoin.pm 36718 2010-02-11 17:21:29Z topia $
3# -----------------------------------------------------------------------------
4# このモジュールは動作時に掲示板のdo-not-touch-mode-of-channelsを使います。
5# -----------------------------------------------------------------------------
6package Channel::Rejoin;
7use strict;
8use warnings;
9use base qw(Module);
10use BulletinBoard;
11use Multicast;
12use RunLoop;
13use NumericReply;
14
15sub new {
16    my $class = shift;
17    my $this = $class->SUPER::new(@_);
18    $this->{sessions} = {}; # チャンネルフルネーム => セッション情報
19    # セッション情報 : HASH
20    # ch_fullname => チャンネルフルネーム
21    # ch_shortname => チャンネルショートネーム
22    # ch => ChannelInfo
23    # server => IrcIO::Server
24    # got_mode => 既にMODEを取得しているかどうか。
25    # got_blist => 既に+bリストを(略
26    # got_elist => +e(略
27    # got_Ilist => +I(略
28    # got_oper => 既にPART->JOINしているかどうか。
29    # cmd_buf => ARRAY<Tiarra::IRC::Message>
30    # num_got_errors => このチャンネルのエラーをみた回数
31    $this;
32}
33
34sub message_arrived {
35    my ($this,$msg,$sender) = @_;
36    if ($sender->isa('IrcIO::Server')) {
37	# PART,KICK,QUIT,KILLが、それぞれ一人になる要因。
38	my $cmd = $msg->command;
39	if ($cmd eq 'PART') {
40	    foreach my $ch_fullname (split /,/,$msg->param(0)) {
41		$this->check_and_rejoin_channel(
42		    scalar Multicast::detatch($ch_fullname),
43		    $sender);
44	    }
45	}
46	elsif ($cmd eq 'KICK') {
47	    # RFC2812によると、複数のチャンネルを持つKICKメッセージが
48	    # クライアントに届く事は無い。
49	    $this->check_and_rejoin_channel(
50		scalar Multicast::detatch($msg->param(0)),
51		$sender);
52	}
53	elsif ($cmd eq 'QUIT' || $cmd eq 'KILL') {
54	    # 註釈affected-channelsに影響のあったチャンネルのリストが入っているはず。
55	    foreach (@{$msg->remark('affected-channels')}) {
56		$this->check_and_rejoin_channel($_,$sender);
57	    }
58	}
59
60	$this->session_work($msg,$sender);
61    }
62    $msg;
63}
64
65sub check_and_rejoin_channel {
66    my ($this,$ch_name,$server) = @_;
67    if ($this->check_channel($ch_name,$server)) {
68	$this->rejoin($ch_name,$server);
69    }
70}
71
72sub check_channel {
73    my ($this,$ch_name,$server) = @_;
74    if ($ch_name =~ m/^\+/) {
75	# +チャンネルに@は付かない。
76	return;
77    }
78    my $ch = $server->channel($ch_name);
79    if (!defined $ch) {
80	# 自分が入っていない
81	return;
82    }
83    if ($ch->switches('a')) {
84	# +aチャンネルでは一人になったかどうかの判定が面倒である上に、
85	# @を復活させる意味も無ければ復活させない方が望ましい。
86	return;
87    }
88    if ($ch->names(undef,undef,'size') > 1) {
89	# 二人以上いる。
90	return;
91    }
92    my $myself = $ch->names($server->current_nick);
93    if (defined $myself && $myself->has_o) {
94	# 自分が@を持っている。
95	return;
96    }
97    if ($ch->remark('chanserv-controlled')) {
98	# ChanServ 管理チャンネルであれば、無駄な努力はしない。
99	return;
100    }
101    return 1;
102}
103
104sub rejoin {
105    my ($this,$ch_name,$server) = @_;
106    my $ch_fullname = Multicast::attach($ch_name,$server->network_name);
107    if (defined $this->{sessions}->{$ch_fullname}) {
108	# 動作中のセッションがあるのでキャンセルする。
109	return;
110    }
111    RunLoop->shared->notify_msg(
112	"Channel::Rejoin is going to rejoin to ${ch_fullname}.");
113
114    ###############
115    #   処理の流れ
116    ### phase 1 ###
117    # セッション作成。
118    # 掲示板に「このチャンネルのモードを変更するな」と書き込む。
119    # TOPICを覚える。
120    # 備考switches-are-knownが偽ならMODE #channel実行。
121    # 必要ならMODE #channel +b,MODE #channel +e,MODE #channel +Iを実行。
122    ### phase 2 ###
123    # 324(modeリプライ),368(+bリスト終わり),
124    # 349(+eリスト終わり),347(+Iリスト終わり)をそれぞれ必要なら待つ。
125    ### phase 3 ###
126    # PART #channel実行。
127    # JOIN #channel実行。
128    # 自分のJOINを待つ。
129    # 少しずつ命令バッファに溜まったコマンドを実行していく。Timer使用。
130    #   命令バッファにはMODEやTOPICが入っている。
131    # 掲示板から消す。
132    # セッションを破棄。
133    ###############
134
135    # チャンネル取得
136    my $ch = $server->channel($ch_name);
137
138    # セッション登録
139    my $session = $this->{sessions}->{$ch_fullname} = {
140	ch_fullname => $ch_fullname,
141	ch_shortname => $ch_name,
142	ch => $ch,
143	server => $server,
144	cmd_buf => [],
145	num_got_errors => 0,
146    };
147
148    # do-not-touch-mode-of-channelsを取得
149    my $untouchables = BulletinBoard->shared->do_not_touch_mode_of_channels;
150    if (!defined $untouchables) {
151	$untouchables = {};
152	BulletinBoard->shared->set('do-not-touch-mode-of-channels',$untouchables);
153    }
154    # このチャンネルをフルネームで登録
155    $untouchables->{$ch_fullname} = 1;
156
157    # TOPICを覚える。
158    if ($ch->topic ne '') {
159	push @{$session->{cmd_buf}},$this->construct_irc_message(
160	    Command => 'TOPIC',
161	    Params => [$ch_name,$ch->topic]);
162    }
163
164    # 必要ならMODE #channel実行。
165    #if ($ch->remarks('switches-are-known')) {
166    #	$session->{got_mode} = 1;
167    #	push @{$session->{cmd_buf}},$this->construct_irc_message(
168    #	    Command => 'MODE',
169    #}
170    # やっぱりやめ。面倒。防衛BOTとして使いたかったらこんなモジュール使わないこと。
171    #else {
172    	$server->send_message(
173    	    $this->construct_irc_message(
174		Command => 'MODE',
175		Param => $ch_name));
176    #}
177
178    # 必要なら+e,+b,+I実行。
179    if ($this->config->save_lists) {
180	foreach (qw/+e +b +I/) {
181	    $server->send_message(
182		$this->construct_irc_message(
183		    Command => 'MODE',
184		    Params => [$ch_name,$_]));
185	}
186	$session->{got_elist} =
187	    $session->{got_blist} =
188	    $session->{got_Ilist} = 0;
189    }
190    else {
191	$session->{got_elist} =
192	    $session->{got_blist} =
193	    $session->{got_Ilist} = 1;
194    }
195
196    # 待たなければならないものはあるか?
197    if ($this->{got_mode} && $this->{got_elist} &&
198	$this->{got_blist} && $this->{got_Ilist}) {
199	# もう何も無い。
200	$this->part_and_join($session);
201    }
202}
203
204sub part_and_join {
205    my ($this,$session) = @_;
206    $session->{got_oper} = 1;
207    if (!$this->check_channel($session->{ch_shortname}, $session->{server})) {
208	# 情報を取得している間に状況が変化した
209	RunLoop->shared->notify_msg(
210	    "Channel::Rejoin is cancelled to rejoin to $session->{ch_fullname}.");
211	# part/join をやめたので発行すべきコマンドはない。
212	$session->{cmd_buf} = [];
213	# フラグ類のクリーンアップを行う
214	$this->revive($session);
215	return;
216    }
217    foreach (qw/PART JOIN/) {
218	$session->{server}->send_message(
219	    $this->construct_irc_message(
220		Command => $_,
221		Param => $session->{ch_shortname}));
222    }
223}
224
225sub session_work {
226    my ($this,$msg,$server) = @_;
227    my $session;
228    # ウォッチの対象になるのはJOIN,324,368,349,347,482。
229    # リストはコマンドを発行していれば IrcIO::Server が
230    # 保持しておいてくれる。
231
232    my $got_reply = sub {
233	my $type = shift;
234	my ($flagname,$listname) = do {
235	    if ($type eq 'b') {
236		('got_blist','banlist');
237	    }
238	    elsif ($type eq 'e') {
239		('got_elist','exceptionlist');
240	    }
241	    elsif ($type eq 'I') {
242		('got_Ilist','invitelist');
243	    }
244	};
245
246	$session = $this->{sessions}->{$msg->param(1)};
247	if (defined $session) {
248	    $session->{$flagname} = 1;
249
250	    my $list = $session->{ch}->$listname();
251	    my $list_size = @$list;
252	    # 3つずつまとめる。
253	    for (my $i = 0; $i < $list_size; $i+=3) {
254		my @masks = ($list->[$i]);
255		push @masks,$list->[$i+1] if $i+1 < $list_size;
256		push @masks,$list->[$i+2] if $i+2 < $list_size;
257
258		push @{$session->{cmd_buf}},$this->construct_irc_message(
259		    Command => 'MODE',
260		    Params => [$session->{ch_shortname},
261			       '+'.($type x scalar(@masks)),
262			       @masks]);
263	    }
264	}
265    };
266
267    if ($msg->command eq RPL_CHANNELMODEIS) {
268	# MODEリプライ
269	$session = $this->{sessions}->{$msg->param(1)};
270	if (defined $session) {
271	    $session->{got_mode} = 1;
272	    my $ch = $session->{ch};
273
274	    my ($params, @params) = $ch->mode_string;
275	    if (length($params) > 1) {
276		# 設定すべきモードがある。
277		push @{$session->{cmd_buf}},$this->construct_irc_message(
278		    Command => 'MODE',
279		    Params => [$session->{ch_shortname},
280			       $params,
281			       @params]);
282	    }
283	}
284    }
285    elsif ($msg->command eq RPL_ENDOFBANLIST) {
286	# +bリスト終わり
287	$got_reply->('b');
288    }
289    elsif ($msg->command eq RPL_ENDOFEXCEPTLIST) {
290	# +eリスト終わり
291	$got_reply->('e');
292    }
293    elsif ($msg->command eq RPL_ENDOFINVITELIST) {
294	# +Iリスト終わり
295	$got_reply->('I');
296    }
297    elsif ($msg->command eq 'JOIN') {
298	$session = $this->{sessions}->{$msg->param(0)};
299	if (defined $session && defined $msg->nick &&
300	    $msg->nick eq RunLoop->shared->current_nick) {
301	    # 入り直した。
302	    $session->{got_oper} = 1; # 既にセットされている筈だが念のため
303	    $this->revive($session);
304	}
305    }
306    elsif ($msg->command eq ERR_CHANOPRIVSNEEDED) {
307	$session = $this->{sessions}->{$msg->param(1)};
308	if (defined $session) {
309	    $session->{num_got_errors}++;
310	}
311    }
312
313    # $sessionが空でなければ、必要な情報が全て揃った可能性がある。
314    if (defined $session && !$session->{got_oper} &&
315	$session->{got_mode} && ($session->{got_blist} +
316	$session->{got_elist} + $session->{got_Ilist} +
317	$session->{num_got_errors}) >= 3) {
318	$this->part_and_join($session);
319    }
320}
321
322sub revive {
323    my ($this,$session) = @_;
324    Timer->new(
325	Name => 'Channel::Rejoin cmd queue',
326	Module => $this,
327	Interval => 1,
328	Repeat => 1,
329	Code => sub {
330	    my $timer = shift;
331	    my $cmd_buf = $session->{cmd_buf};
332	    if (@$cmd_buf > 0) {
333		# 一度に二つずつ送り出す。
334		my $msg_per_trigger = 2;
335		for (my $i = 0; $i < @$cmd_buf && $i < $msg_per_trigger; $i++) {
336		    $session->{server}->send_message($cmd_buf->[$i]);
337		}
338		splice @$cmd_buf,0,$msg_per_trigger;
339	    }
340	    if (@$cmd_buf == 0) {
341		# cmd_bufが空だったら終了。
342		# ただし、10秒以内に再び単独になっても無視する
343		# untouchablesから消去
344		my $untouchables = BulletinBoard->shared->do_not_touch_mode_of_channels;
345		delete $untouchables->{$session->{ch_fullname}};
346		Timer->new(
347		    Name => 'Channel::Rejoin delay cleanup',
348		    Module => $this,
349		    After => 10,
350		    Code => sub {
351			# session消去
352			delete $this->{sessions}->{$session->{ch_fullname}};
353		    })->install;
354		# タイマーをアンインストール
355		$timer->uninstall;
356	    }
357	})->install;
358}
359
3601;
361
362=pod
363info: チャンネルオペレータ権限を無くしたとき、一人ならjoinし直す。
364default: off
365section: important
366
367# +チャンネルや+aされているチャンネル以外でチャンネルオペレータ権限を持たずに
368# 一人きりになった時、そのチャンネルの@を復活させるために自動的にjoinし直すモジュール。
369# トピック、モード、banリスト等のあらゆるチャンネル属性をも保存します。
370
371# +b,+I,+eリストの復旧を行なうかどうか。
372# あまりに長いリストを取得するとMax Send-Q Exceedで落とされるかも知れません。
373save-lists: 1
374=cut
375