1# -----------------------------------------------------------------------------
2# $Id: Channel.pm 36686 2010-02-10 18:47:59Z topia $
3# -----------------------------------------------------------------------------
4package Log::Channel;
5use strict;
6use warnings;
7use IO::File;
8use File::Spec;
9use Tiarra::Encoding;
10use base qw(Module);
11use Module::Use qw(Tools::DateConvert Log::Logger Log::Writer);
12use Tools::DateConvert;
13use Log::Logger;
14use Log::Writer;
15use Module::Use qw(Tools::HashTools);
16use Tools::HashTools;
17use ControlPort;
18use Mask;
19use Multicast;
20
21our $DEFAULT_FILENAME_ENCODING = $^O eq 'MSWin32' ? 'sjis' : 'utf8';
22
23sub new {
24    my $class = shift;
25    my $this = $class->SUPER::new(@_);
26    $this->{channels} = []; # 要素は[ディレクトリ名,マスク]
27    $this->{matching_cache} = {}; # <チャンネル名,ファイル名>
28    $this->{writer_cache} = {}; # <チャンネル名,Log::Writer>
29    $this->{sync_command} = do {
30	my $sync = $this->config->sync;
31	if (defined $sync) {
32	    uc $sync;
33	}
34	else {
35	    undef;
36	}
37    };
38    $this->{distinguish_myself} = do {
39	my $conf_val = $this->config->distinguish_myself;
40	if (defined $conf_val) {
41	    $conf_val;
42	}
43	else {
44	    1;
45	}
46    };
47    $this->{logger} =
48	Log::Logger->new(
49	    sub {
50		$this->_search_and_write(@_);
51	    },
52	    $this,
53	    'S_PRIVMSG','C_PRIVMSG','S_NOTICE','C_NOTICE');
54
55    $this->_init;
56}
57
58sub _init {
59    my $this = shift;
60    foreach ($this->config->channel('all')) {
61	my ($dirname,$mask) = split /\s+/;
62	if (!defined($dirname) || $dirname eq '' ||
63	    !defined($mask) || $mask eq '') {
64	    die 'Illegal definition in '.__PACKAGE__."/channel : $_\n";
65	}
66	push @{$this->{channels}},[$dirname,$mask];
67    }
68
69    $this;
70}
71
72sub sync {
73    my $this = shift;
74    $this->flush_all_file_handles;
75    RunLoop->shared->notify_msg("Channel logs synchronized.");
76}
77
78sub control_requested {
79    my ($this,$request) = @_;
80    if ($request->ID eq 'synchronize') {
81	$this->sync;
82	ControlPort::Reply->new(204,'No Content');
83    }
84    else {
85	die "Log::Channel received control request of unsupported ID ".$request->ID."\n";
86    }
87}
88
89sub message_arrived {
90    my ($this,$message,$sender) = @_;
91
92    # syncは有効で、クライアントから受け取ったメッセージであり、かつ今回のコマンドがsyncに一致しているか?
93    if (defined $this->{sync_command} &&
94	$sender->isa('IrcIO::Client') &&
95	$message->command eq $this->{sync_command}) {
96	# 開いているファイルを全てflush。
97	# 他のモジュールも同じコマンドでsyncするかも知れないので、
98	# do-not-send-to-servers => 1は設定するが
99	# メッセージ自体は破棄してしまわない。
100	$this->sync;
101	$message->remark('do-not-send-to-servers',1);
102	return $message;
103    }
104
105    # __PACKAGE__/commandにマッチするか?
106    if (Mask::match(lc($this->config->command || '*'),lc($message->command))) {
107	$this->{logger}->log($message,$sender);
108    }
109
110    $message;
111}
112
113{
114    no warnings qw(once);
115    *S_PRIVMSG = \&PRIVMSG_or_NOTICE;
116    *S_NOTICE = \&PRIVMSG_or_NOTICE;
117    *C_PRIVMSG = \&PRIVMSG_or_NOTICE;
118    *C_NOTICE = \&PRIVMSG_or_NOTICE;
119}
120
121sub PRIVMSG_or_NOTICE {
122    my ($this,$msg,$sender) = @_;
123    my $target = Multicast::detatch($msg->param(0));
124    my $is_priv = Multicast::nick_p($target);
125    my $cmd = $msg->command;
126
127    my $line = do {
128	if ($is_priv) {
129	    # privの時は自分と相手を必ず区別する。
130	    if ($sender->isa('IrcIO::Client')) {
131		sprintf(
132		    $cmd eq 'PRIVMSG' ? '>%s< %s' : ')%s( %s',
133		    $msg->param(0),
134		    $msg->param(1));
135	    }
136	    else {
137		sprintf(
138		    $cmd eq 'PRIVMSG' ? '-%s- %s' : '=%s= %s',
139		    $msg->nick || $sender->current_nick,
140		    $msg->param(1));
141	    }
142	}
143	else {
144	    my $format = do {
145		if ($this->{distinguish_myself} && $sender->isa('IrcIO::Client')) {
146		    $cmd eq 'PRIVMSG' ? '>%s:%s< %s' : ')%s:%s( %s';
147		}
148		else {
149		    $cmd eq 'PRIVMSG' ? '<%s:%s> %s' : '(%s:%s) %s';
150		}
151	    };
152	    my $nick = do {
153		if ($sender->isa('IrcIO::Client')) {
154		    RunLoop->shared_loop->network(
155		      (Multicast::detatch($msg->param(0)))[1])
156			->current_nick;
157		}
158		else {
159		    $msg->nick || $sender->current_nick;
160		}
161	    };
162	    sprintf $format,$msg->param(0),$nick,$msg->param(1);
163	}
164    };
165
166    [$is_priv ? 'priv' : $msg->param(0),$line];
167}
168
169sub _channel_match {
170    # 指定されたチャンネル名にマッチするログ保存ファイルのパターンを定義から探す。
171    # 一つもマッチしなければundefを返す。
172    # このメソッドは検索結果を$this->{matching_cache}に保存して、後に再利用する。
173    my ($this,$channel) = @_;
174
175    my $cached = $this->{matching_cache}->{$channel};
176    if (defined $cached) {
177	if ($cached eq '') {
178	    # マッチするエントリは存在しない、という結果がキャッシュされている。
179	    return undef;
180	}
181	else {
182	    return $cached;
183	}
184    }
185
186    foreach my $ch (@{$this->{channels}}) {
187	if (Mask::match($ch->[1],$channel)) {
188	    # マッチした。
189	    my $fname_format = $this->config->filename || '%Y.%m.%d.txt';
190	    # あまり好ましくなさそうな文字はあらかじめエスケープ.
191	    my $chan_filename = $channel;
192	    $chan_filename =~ s/![0-9A-Z]{5}/!/;
193	    $chan_filename =~ s{([^-\w@#%!+&.\x80-\xff])}{
194	      sprintf('=%02x', unpack("C", $1));
195	    }ge;
196	    my $chan_dir = Tools::HashTools::replace_recursive(
197		$ch->[0], [{channel => $chan_filename, lc_channel => lc $chan_filename}]);
198	    my $fpath_format = "$chan_dir/$fname_format";
199
200	    $this->{matching_cache}->{$channel} = $fpath_format;
201	    return $fpath_format;
202	}
203    }
204    $this->{matching_cache}->{$channel} = '';
205    undef;
206}
207
208sub _search_and_write {
209    my ($this,$channel,$line) = @_;
210    my $dirname = $this->_channel_match($channel);
211    if (defined $dirname) {
212	$this->_write($channel,$dirname,$line);
213    }
214}
215
216sub _write {
217    # 指定されたログファイルにヘッダ付きで追記する。
218    # ディレクトリ名の日付のマクロは置換される。
219    my ($this,$channel,$abstract_fpath,$line) = @_;
220    my $concrete_fpath = do {
221	my $basedir = $this->config->directory;
222	if (defined $basedir) {
223	    Tools::DateConvert::replace("$basedir/$abstract_fpath");
224	}
225	else {
226	    Tools::DateConvert::replace($abstract_fpath);
227	}
228    };
229    my $filename_encoding = $this->config->filename_encoding || $DEFAULT_FILENAME_ENCODING;
230    if( $filename_encoding ne 'ascii' )
231    {
232      $concrete_fpath = Tiarra::Encoding->new($concrete_fpath)->conv($filename_encoding);
233    }else
234    {
235      $concrete_fpath =~ s/([^ -~])/sprintf('=%02x', unpack("C", $1))/ge;
236    }
237    my $header = Tools::DateConvert::replace(
238	$this->config->header || '%H:%M'
239    );
240    my $always_flush = do {
241	if ($this->config->keep_file_open) {
242	    if ($this->config->always_flush) {
243		1;
244	    } else {
245		0;
246	    }
247	} else {
248	    1;
249	}
250    };
251    # ファイルに追記
252    my $make_writer = sub {
253	Log::Writer->shared_writer->find_object(
254	    $concrete_fpath,
255	    always_flush => $always_flush,
256	    file_mode_oct => $this->config->mode,
257	    dir_mode_oct => $this->config->dir_mode,
258	   );
259    };
260    my $writer = sub {
261	# キャッシュは有効か?
262	if ($this->config->keep_file_open) {
263	    # このチャンネルはキャッシュされているか?
264	    my $cached_elem = $this->{writer_cache}->{$channel};
265	    if (defined $cached_elem) {
266		# キャッシュされたファイルパスは今回のファイルと一致するか?
267		if ($cached_elem->uri eq $concrete_fpath) {
268		    # このファイルハンドルを再利用して良い。
269		    #print "$concrete_fpath: RECYCLED\n";
270		    return $cached_elem;
271		}
272		else {
273		    # ファイル名が違う。日付が変わった等の場合。
274		    # 古いファイルハンドルを閉じる。
275		    #print "$concrete_fpath: recached\n";
276		    eval {
277			$cached_elem->flush;
278			$cached_elem->unregister;
279		    };
280		    # 新たなファイルハンドルを生成。
281		    $cached_elem = $make_writer->();
282		    if (defined $cached_elem) {
283			$cached_elem->register;
284		    }
285		    return $cached_elem;
286		}
287	    }
288	    else {
289		# キャッシュされていないので、ファイルハンドルを作ってキャッシュ。
290		#print "$concrete_fpath: *cached*\n";
291		my $cached_elem =
292		    $this->{writer_cache}->{$channel} =
293			$make_writer->();
294		if (defined $cached_elem) {
295		    $cached_elem->register;
296		}
297		return $cached_elem;
298	    }
299	}
300	else {
301	    # キャッシュ無効。
302	    return $make_writer->();
303	}
304    }->();
305    if (defined $writer) {
306	$writer->reserve(
307	    Tiarra::Encoding->new("$header $line\n",'utf8')->conv(
308		$this->config->charset || 'jis'));
309    } else {
310	# XXX: do warn with properly frequency
311	#RunLoop->shared_loop->notify_warn("can't write to $concrete_fpath: ".
312	#				      "$header $line");
313    }
314}
315
316sub flush_all_file_handles {
317    my $this = shift;
318    foreach my $cached_elem (values %{$this->{writer_cache}}) {
319	eval {
320	    $cached_elem->flush;
321	};
322    }
323}
324
325sub destruct {
326    my $this = shift;
327    # 開いている全てのLog::Writerを閉じて、キャッシュを空にする。
328    foreach my $cached_elem (values %{$this->{writer_cache}}) {
329	eval {
330	    $cached_elem->flush;
331	    $cached_elem->unregister;
332	};
333    }
334    %{$this->{writer_cache}} = ();
335}
336
3371;
338
339=pod
340info: チャンネルやprivのログを取るモジュール。
341default: off
342section: important
343
344# Log系のモジュールでは、以下のように日付や時刻の置換が行なわれる。
345# %% : %
346# %Y : 年(4桁)
347# %m : 月(2桁)
348# %d : 日(2桁)
349# %H : 時間(2桁)
350# %M : 分(2桁)
351# %S : 秒(2桁)
352
353# ログを保存するディレクトリ。Tiarraが起動した位置からの相対パス。~指定は使えない。
354directory: log
355
356# ログファイルの文字コード。省略されたらjis。
357charset: utf8
358
359# 各行のヘッダのフォーマット。省略されたら'%H:%M'。
360header: %H:%M:%S
361
362# ファイル名のフォーマット。省略されたら'%Y.%m.%d.txt'
363filename: %Y.%m.%d.txt
364
365# ログファイルのモード(8進数)。省略されたら600
366mode: 600
367
368# ログディレクトリのモード(8進数)。省略されたら700
369dir-mode: 700
370
371# ログを取るコマンドを表すマスク。省略されたら記録出来るだけのコマンドを記録する。
372command: privmsg,join,part,kick,invite,mode,nick,quit,kill,topic,notice
373
374# PRIVMSGとNOTICEを記録する際に、自分の発言と他人の発言でフォーマットを変えるかどうか。1/0。デフォルトで1。
375distinguish-myself: 1
376
377# 各ログファイルを開きっぱなしにするかどうか。
378# このオプションは多くの場合、ディスクアクセスを抑えて効率良くログを保存しますが
379# ログを記録すべき全てのファイルを開いたままにするので、50や100のチャンネルを
380# 別々のファイルにログを取るような場合には使うべきではありません。
381# 万一 fd があふれた場合、クライアントから(またはサーバへ)接続できない・
382# 新たなモジュールをロードできない・ログが全然できないなどの症状が起こる可能性が
383# あります。limit の詳細については OS 等のドキュメントを参照してください。
384-keep-file-open: 1
385
386# keep-file-open 時に各行ごとに flush するかどうか。
387# open/close の負荷は気になるが、ログは失いたくない人向け。
388# keep-file-open が有効でないなら無視され(1になり)ます。
389-always-flush: 0
390
391# keep-file-openを有効にした場合、発言の度にログファイルに追記するのではなく
392# 一定の分量が溜まってから書き込まれる。そのため、ファイルを開いても
393# 最近の発言はまだ書き込まれていない可能性がある。
394# syncを設定すると、即座にログをディスクに書き込むためのコマンドが追加される。
395# 省略された場合はコマンドを追加しない。
396sync: sync
397
398# 各チャンネルの設定。チャンネル名の部分はマスクである。
399# 個人宛てに送られたPRIVMSGやNOTICEはチャンネル名"priv"として検索される。
400# 記述された順序で検索されるので、全てのチャンネルにマッチする"*"などは最後に書かなければならない。
401# 指定されたディレクトリが存在しなかったら、Log::Channelはそれを勝手に作る。
402# フォーマットは次の通り。
403# channel: <ディレクトリ名> (<チャンネル名> / 'priv')
404# 例:
405# filename: %Y.%m.%d.txt
406# channel: IRCDanwasitu #IRC談話室@ircnet
407# channel: others *
408# この例では、#IRC談話室@ircnetのログはIRCDanwasitu/%Y.%m.%d.txtに、
409# それ以外(privも含む)のログはothers/%Y.%m.%d.txtに保存される。
410# #(channel) はチャンネル名に展開される。
411# (古いバージョンだと展開されずにそのままディレクトリ名になってしまいます。)
412# IRCのチャンネル名は大文字小文字が区別されず、サーバからは各送信者が指定した通りの
413# チャンネル名が送られてきます。そのため、大文字小文字が区別されるファイルシステムでは
414# 同じチャンネルが別々のディレクトリに作られることになります。
415# この問題を回避するため、チャンネル名を小文字に統一した #(lc_channel) が利用できます。
416channel: priv priv
417channel: #(lc_channel) *
418-channel: others *
419
420# ファイル名のエンコーディング.
421# 指定可能な値は, utf8, sjis, euc, jis, ascii.
422# ascii は実際には utf8 と同等で8bit部分が全てquoted-printableされる.
423# デフォルトはWindowsではsjis, それ以外では utf8.
424-filename-encoding: utf8
425
426=cut
427