IRCボットの実装で見るスクリプト言語

PHP+SmartIRCでIRCボットを書きたいというお話で,SmartIRCなるライブラリの存在を始めて知った.他の言語にある,IRCクライアントライブラリは幾つか知っているけど,それらも分かるのは名前ぐらいで,使い方はさっぱりだ.
それならこれを機に使い方(言語もライブラリも)を憶えようと言うことで,有名なスクリプト言語でIRCボットを実装していくひとりツアーを開催することにしたのだった.わーわー.

IRCボットの仕様

仕様は以下の通り.

  • 4桁ないし5桁のポート番号を発言するとネットワーク対戦ゲームの募集文をNOTICE発言する
  • "good night, jewel."と発言すると落ちる

IRCボットライブラリの出来だけではなく,DNSリゾルバライブラリの有無や出来まで問われることになるのは想定外だったけど,なかなか楽しいツアーになった.

PHP

PHPは``<?php"と``?>"で挟まれた領域を,PHPのプログラムコードとして解釈する.それ以外の領域はそのまま出力するようになっている.主にHTMLソースに埋め込んで使われるが,その使用法からすると,HTMLのプリプロセッサという位置づけなのかも知れない.
本題のIRCボットの実装だけど,PHPではNet_SmartIRCというライブラリを使用することによって,簡単にIRCボットを作成することが出来る.

Net_SmartIRC is a PHP class for communication with IRC networks

PEAR - Net_SmartIRC

正規表現の条件によって,指定したルーチンを呼び出す機能が実装されているので,自前でディスパッチする手間を省くことが出来る.しかし,この機能では複雑な起動条件を指定することは出来ず,それを行うには,自前でディスパッチする必要がある.
また,パターンマッチングにおける文字符号化方式の問題も存在する.Net_SmartIRCは,受信した文字列を符号化方式変換せず,そのままパターンマッチング処理に掛けるようになっている.ソースコードの符号化方式と異なる文字列をパターンマッチングして一致させるには,mb_convert_encoding等を用いて,正規表現文字列の符号化方式を受信文字列に合わせるか,ソースコードの符号化方式を受信文字列に合わせる必要がある.
IRCで日本語文を送信する際,freenodeではUTF-8,IRCNetではiso-2022-jpと,ネットワークによって様々な符号化方式が使用されているけど,後者のiso-2022-jpにmb_convert_encodingで変換して用いようとすると,正規表現コンパイルエラーとなってしまう.エスケープシーケンスの中に正規表現では特別な意味を持つ括弧が含まれているためだ.

// ソースの符号化方式がSJISのとき。
// これは正規表現コンパイルエラーになる
$irc->registerActionHandler(SMARTIRC_TYPE_CHANNEL, $bot->encode('何か日本語'), $bot, 'onSomeMacro');

これを回避するには,全ての発言に反応する処理を登録し,そこで文字列変換を行って,自前でディスパッチする必要がある.これではこの機能が無意味化してしまう・・・とはいってもどうしようもないので,日本語を使う場合はこの方法で行くことにした.
ホスト名からIPアドレスを得るにはNet_DNSライブラリを用いる.

Resolver library used to communicate with a DNS server.

PEAR - Net_DNS

ドキュメントが貧弱で,使い方がわかりにくい.調べた結果,とりあえず次のコードが理解できるまでにはなった.DNS_SERVER_HOSTで指定したDNSサーバに$hostに束縛されたホスト名を問い合わせて,結果からAレコードに含まれるアドレス,つまり正引きされたIPアドレスを取得する.

include_once "Net/DNS.php";
define("DNS_SERVER_HOST", "DNSサーバのホスト名");
$result = null;
// リゾルバクラスのインスタンスを生成
$resolver = new Net_DNS_Resolver(array(
	'nameservers' => array(DNS_SERVER_HOST)
));
// 問い合わせる
if ($response = $resolver->query($host)) {
	// 問い合わせ結果からAレコードを探す
	foreach ($response->answer as $rr) {
		if ($rr->type === 'A') {
			$result = $rr->address;
			break;
		}
	}
}

これらのライブラリを組み合わせて目的物をさくっと仕上げた.

<?php
include_once "Net/SmartIRC.php";
include_once "Net/DNS.php";

// システム環境文字列をShift_JIS(Windows仕様)と見なす
mb_internal_encoding("sjis-win");

define("IRC_ENCODING", "iso-2022-jp");
define("DNS_SERVER_HOST", "192.168.3.1");

class MyIRCBot {
	// ホスト名に対応するIPアドレスを取得する
	function dnsLookup($host) {
		$result = null;
		$resolver = new Net_DNS_Resolver(array(
			'nameservers' => array(DNS_SERVER_HOST)
		));
		if ($response = $resolver->query($host)) {
			foreach ($response->answer as $rr) {
				if ($rr->type === 'A') {
					$result = $rr->address;
					break;
				}
			}
		}
		return $result;
	}

	// 募集マクロのルーチン
	function onGameOffer(&$irc, &$data) {
		// 発言者のIPアドレスを引く
		if ($address = $this->dnsLookup($data->host)) {
			// NOTICEで募集要項を発言する
			$irc->message(SMARTIRC_TYPE_NOTICE, $data->channel, $this->encode(
				$data->nick . "さんが" . 
				$address . ":" . $data->message .
				"で募集中だよ。")
			);
		}
	}

	// ボット落とすマクロのルーチン
	function onQueryQuit(&$irc, &$data) {
		$irc->quit($this->encode("good bye."));
	}

	// PHPシステム環境文字列→IRC日本語文字列の変換
	function encode($str) {
		return mb_convert_encoding($str, IRC_ENCODING);
	}

	// IRC日本語文字列→PHPシステム環境文字列の変換
	function decode($str) {
		return mb_convert_encoding($str, mb_internal_encoding(), IRC_ENCODING);
	}
}

// 各種クラスをインスタンス化
$bot = &new MyIRCBot();
$irc = &new Net_SmartIRC();
// デバッグ情報を全部表示する
$irc->setDebug(SMARTIRC_DEBUG_ALL);
// ネイティブソケットを使うようにする
$irc->setUseSockets(TRUE);

// 募集マクロを登録
$irc->registerActionHandler(SMARTIRC_TYPE_CHANNEL, $bot->encode('^[1-9]\d{3,4}$'), $bot, 'onGameOffer');
// ボット落とすマクロを登録
$irc->registerActionHandler(SMARTIRC_TYPE_CHANNEL, $bot->encode('^good night, jewel.$'), $bot, 'onQueryQuit');

// IRCサーバに接続,ログインする
$irc->connect('ircnet.eversible.com', 6667);
$irc->login('anon_193', 'RealName', 0, 'anon_193');
// 接続できたらチャンネルに入る
$irc->join(array($bot->encode('#some_irc_room')));

// メインループに入る
$irc->listen();
?>

いざ書いてみると,結構簡素に書けることが分かった.DNSサーバとしてWindowデフォルトのを引いてくれれば良いんだけど・・・
書いて分かったことは,

  • ライブラリのロードはinclude_once "ライブラリ名"
  • 定数の定義はdefine("定数名", 定数値)
  • クラスの定義はclass クラス名 { 定義 }
  • 関数の定義はfunction 関数名(仮引数) { 定義 }
  • 条件分岐はif (条件式) { then節 }
  • 列挙はforeach (列挙可能なオブジェクト as 要素) { æ–‡ }
  • 変数の接頭辞は$
  • インスタンスメンバのアクセスは->を用いる
  • 連想配列はarray(キー => 値)
  • 文字列は'',""の書き方がある
  • 参照渡し?に接頭辞&がある

定数の定義が不思議だが,それ以外はJavascriptやRuby,C++等を主に使っている所為か,割と親しみの持てる手触りだった.それに,素直に書けるのは重要なことだ.

Ruby

既に色々使っているので何も言うことはない.
RubyでIRCボットを書くには,net/ircと言うライブラリを使う.IRCクライアントクラスNet::IRC::Clientを継承して,各種イベントハンドラを定義,ボット処理のルーチンにディスパッチするという形で実装する.
ホスト名からIPアドレスを引く方法は幾つかあるけど,IPSocket#getaddressが最も簡単に引くことができる.具体的なコードは以下の通り.

require 'socket'
address = IPSocket.getaddress("ホスト名")

ホストが複数のアドレスを持つ場合の挙動は調べていない.例外が発生するかも知れない.
流石メインで使っている言語だけ在って実装はあっという間に出来上がった.

#!ruby -Ks

IRC_ENCODING='iso-2022-jp'

require 'rubygems'
require 'net/irc'
require 'pp'
require 'socket'

class IRCBot < Net::IRC::Client
	def initialize(*args)
		super
	end

	def on_rpl_welcome(m)
		post JOIN, "#some_irc_room".encode(IRC_ENCODING).force_encoding('external')
	end

	def on_privmsg(m)
		msg = m[1].encode('external')
		if msg =~ /^[1-9]\d{3,4}$/ then
			on_gameoffer m
		elsif msg =~ /^good night, jewel.$/ then
			on_queryquit m
		end
	end

	def on_gameoffer(m)
		if (address = IPSocket.getaddress(m.prefix.host)) != nil then
			post NOTICE, m[0],
				(m.prefix.nick + "さんが" +
				address	+ ":" + m[1] +
				"で募集中だよ。").encode(IRC_ENCODING).force_encoding('external')
		end		
	end

	def on_queryquit(m)
		post QUIT, "good bye."
	end	
end

IRCBot.new("ircnet.eversible.com", 6667, {
	:nick => "anon_193", :user => "anon_193", :real => "anon_193"
}).start

コードから分かることは

  • 定数の定義は大文字?
  • クラスの定義はclass クラス名 < 継承元クラス 定義 end
  • 関数の定義はdef 関数名(仮引数) 定義 end
  • 条件分岐はif 条件式 then節 [elsif 条件式 then節] end
  • インスタンスメンバへのアクセスは.(半角ピリオド)
  • 連想配列は{ :キー => 値 }
  • 正規表現リテラルがある
  • 正規表現によるパターンマッチングは=~演算子

使っているので慣れたものだけど,elsifがちょっと不満かな.elsifはまとまっているという点で良いけど,心情的にelse ifの方が好き.

Python

人気なのか海外でよく見るスクリプト言語.他の言語では見た目を整える程度の意味しかないインデントが,この言語ではブロックを表す等重要な意味を持つらしい.Lispは括弧地獄でキライとか思ってるけど,ALGOL系の言語も大概括弧対応でハマる.その煩わしさから解放されるという点では,良い言語仕様じゃなかろうかと最近思い始めてきた.また,この言語には一つのことには一つの方法とか言う信条があるらしいけど,触れたばかりなので深いことは余りよく分からない.
さて,PythonでIRCボットを書くには,python-irclibというライブラリを使う.Rubyと同じようにIRCクライアントクラスSingleServerIRCBotを継承して,そのイベントハンドラを記述するという形になる.しかしこのpython-irclib,ドキュメントの不整備振りがひどいもので,最終的に,Web上で公開されている公式でないサンプルとライブラリのソースコードをマニュアル代わりにするハメになった.もう窓から投げ捨てたい.
ホスト名からIPアドレスを引くには,python-pydnsと言うライブラリを使う.これもpython-irclibに引けを取らず,ドキュメントの不整備振りが目に付く.ソースコードがマニュアルと言わんばかりの男気溢れるライブラリは言語の読解力向上に一役買ってくれるけど,それと引き替えにごっそり活動エネルギーと活動時間を持って行かれるのはきつい.
pydnsはDNSパッケージの1パッケージで構成されている.DNSサーバリストは初期状態では空で,リゾルバクラスの生成に応じてどこぞからロードしてくれるなんて機能はない.DNS.DiscoverNameServersメソッドを明示的に呼び出すことによって,サーバリストがロードされる.このメソッドは動作プラットフォームによって動作を切り替えていて,Windowsではレジストリから,その他ではresolv.confから得るようになっている.
pydnsを用いて指定したホスト名からIPアドレスを得るコードは次のようになる.

def dns_lookup(host):
	DNS.DiscoverNameServers()
	ans = DNS.Request(host).req().answers
	for rec in ans:
		if rec['typename'] == 'A':
			return rec['data']
	return null

req()メソッドでリクエストを発行する.req()メソッドの返り値が持つanswersメンバが列挙可能なレスポンスを表している,らしい.各要素は連想配列でtypenameキーを持ち,Aレコードの場合は,dataキーにIPアドレスを保持している,らしい.
実装はRubyと似たような感じでさっくりと実装できた.

# coding: shift_jis

IRC_SERVER = "ircnet.eversible.com"
IRC_PORT = 6667
IRC_NICK = "anon_193"

import re
import DNS
from irclib import nm_to_n
from irclib import nm_to_h
from ircbot import SingleServerIRCBot
	
class IRCBot(SingleServerIRCBot):
	def __init__(self):
		print ("connecting.")
		SingleServerIRCBot.__init__(self, [(IRC_SERVER, IRC_PORT)], IRC_NICK, IRC_NICK)
		self.channel = u"#some_irc_room".encode("iso-2022-jp")

	def on_welcome(self, irc, e):
		print ("on_welcome")
		irc.join(self.channel)

	def on_privmsg(self, irc, e):
		try:
			nick = nm_to_n(e.source())
			host = nm_to_h(e.source())
			msg = unicode(e.arguments()[0], "iso-2022-jp")
		except Exception, e:
			print(str(e))
			return

		e.nick = nick
		e.host = host
		e.msg = msg

		if re.match("^[1-9]\d{3,4}$", msg):
			self.on_gameoffer(e)
		elif re.match("^good night, jewel.$", msg):
			self.on_queryquit(e)

	def on_gameoffer(self, e):
		addr = self.dns_lookup(e.host)
		if addr:
			msg = e.nick + u"が" + addr + u":" + e.msg + u"で募集中だよ。"
			self.connection.notice(self.channel, msg.encode("iso-2022-jp"))

	def on_queryquit(self, e):
		self.connection.quit("good bye.")

	def dns_lookup(self, host):
		DNS.DiscoverNameServers()
		ans = DNS.Request(host).req().answers
		for rec in ans:
			if rec['typename'] == 'A':
				return rec['data']
		return null		

	on_pubmsg = on_privmsg

bot = IRCBot()
bot.start()

コードを書いて分かったことは,

  • ライブラリのインポートはimport ライブラリ名
  • from ライブラリ名 import 識別子 で個別にインポートすることも出来る
  • クラスの定義はclass クラス名(継承元クラス): インデント下げで定義内容
  • 関数の定義はdef 関数名(仮引数): インデント下げで定義内容
  • 列挙は for 要素 in コレクション: インデント下げで繰り返し文
  • 条件分岐は if 条件式: インデント下げでthen節 [elif 条件式: インデント下げでthen節]
  • インスタンスメンバへのアクセスは.(半角ピリオド)
  • 文字列リテラルは''と""
  • 日本語文字列リテラルは文字列リテラルの前にuをつける(unicodeの略?)
  • クラスのコンストラクタは__init__関数
  • this変数の存在を関数の引数として明記している
  • 文末記号がない.文末=改行?

とにかくインデントがコードのブロックとして意味を持っている.括弧をコードのブロックにしている言語と比べてみると,確かにすっきりしているかも知れない.ブロックの終わりにちょっと違和感を感じるけど.

Perl

変態言語(褒め言葉
設計当初はBASIC的な何かを目指していたと聞いているが・・・オブジェクト周りの記述がかなりフリーダムだった記憶が幽かにある.C++ライクな書き方からSmalltalk的なメッセージパッシング風の書き方まで,見たいな.
PerlでIRC周りを世話するには,POEを使う.

POE is a framework for creating event-driven cooperative multitasking programs in Perl.

POE: Perl Object Environment

POEはPerlで書かれたイベント駆動アプリケーションのためのフレームワークで,様々なサービス(コンポーネント)をこの上で動かすことができるらしい.POEコンポーネント(PoCo)のひとつ、POE::Component::IRCがIRCクライアントとしての役割を果たしてくれる.POEの構造のおかげで,手続きが他の言語と比べて少々複雑になっている.
ホスト名からIPアドレスを引くにはNet::DNSを使う.使い方は他の言語(Ruby除く)とほぼ同じようだった.
POEの所為でややこしかったので,サンプルを見ながら見よう見まねで実装した.

use utf8;
use strict;
use Encode;
use Net::DNS;
use POE qw(Component::IRC);


my $ircserver = 'ircnet.eversible.com';
my $ircport = 6667;
my $nickname = 'anon_193';
my $ircencoding = 'iso-2022-jp-ext';

my $irc = POE::Component::IRC->spawn(
	nick => $nickname,
	username => $nickname,
	ircname => $nickname,
	server => $ircserver,
	port => $ircport
) or die "$!";

POE::Session->create(
	package_states => [
		'main' => [ qw(_start irc_001 irc_public) ],
	],
	heap => { irc => $irc },
);

$poe_kernel->run();

sub _start {
	my ($kernel, $heap) = @_[KERNEL, HEAP];
	my $irc_session = $heap->{irc}->session_id();
	$kernel->post( $irc_session => register => 'all');
	$kernel->post( $irc_session => connect => {} );
}

sub irc_001 {
	my ($kernel, $sender) = @_[KERNEL, SENDER];
	my $channel = irc_encode("#some_irc_room");
	$kernel->post( $sender => join => $channel);
}

sub irc_public {
	my ($kernel, $sender, $who, $where, $what) = @_[KERNEL, SENDER, ARG0, ARG1, ARG2];

	if ( $what =~ /^[1-9]\d{3,4}$/ ) {
		irc_gameoffer($kernel, $sender, $who, $where, $what);
	} elsif ( $what =~ /^good night, jewel.$/ ) {
		irc_queryquit($kernel, $sender, $who, $where, $what);
	}
}

sub irc_gameoffer {
	my ($kernel, $sender, $who, $where, $what) = @_;
	my $nick = ( split /!/, $who )[0];
	my $host = ( split /@/, $who )[1];
	my $channel = $where->[0];

	my $addr = dns_lookup($host);
	if ( $addr ) {
		my $msg = $nick . "さんが" . $addr . ":" . $what . "で募集中だよ。";
		$kernel->post( $sender => notice => $channel => irc_encode($msg) );
	}
}

sub irc_queryquit {
	my ($kernel, $sender) = @_;
	$kernel->post( $sender => quit => "good bye." );
}

sub irc_decode {
	my ($str) = @_;
	Encode::decode($ircencoding, $str);
}

sub irc_encode {
	my ($str) = @_;
	Encode::encode($ircencoding, $str);
}

sub dns_lookup {
	my ($host) = @_;
	my $res = Net::DNS::Resolver->new;
	if ( my $query = $res->search($host, 'A') ) {
		my @arec = grep($_->type eq 'A', $query->answer);
		return $arec[0]->address if @arec > 0; 
	}
}

コードを書いて分かったこと

  • 関数定義はsub 関数名 { 定義 } で仮引数指定はない
  • 関数引数のフェッチは my (変数, ...) = @_;
  • 局所変数定義はmy 変数名 [= 初期値];
  • 変数の接頭辞は$(スカラ,配列アクセス時), @(配列)
  • インスタンスメンバのアクセスは->
  • 名前付き引数?は( 引数名 => 引数値, ... )
  • 識別子リスト?はqw( 識別子 ... )
  • 文字列は''か""
  • 正規表現リテラルがある
  • 正規表現によるパターンマッチングは=~
  • 文字列結合は.(半角ピリオド)

_startハンドラで,IRCコンポーネントをたたき起こす必要があるのが,他の言語とは一味違う感じがした.言語仕様の視点では,変数の接頭辞が憶えられなくて困る.配列向けに$#なんてのもあるらしい.格変化みたいなものだと思えばいいんだと思うけど,やっぱり受け付けにくい.あと,ちゃんとした意味論がくみ取れないものでも,直感的にこんな感じかな?と分かる点で,この言語は興味深い.暗号的だけど.

今回実装しなかった言語たち

groovy

Javaの上で動くスクリプト言語.JavaのIRCクライアントライブラリが使えるそうなので,これでも実装できそう.

Tcl

Tcl/Tkと言う名前でしか知らない.IRKというライブラリがあるけど,αリリースで,最終更新が7年前.実装できるかどうか怪しい.

OCaml

スクリプト言語ではないけど,使ってみた感じではスクリプト言語的な雰囲気がした.ライブラリはないけど,とある最小IRCクライアントの実装で,再利用可能なコンポーネントが含まれている.一度目を通した感じでは,ちょっとラップの層が薄めだけど,これを使えばなんとか書けそう.

ツアーは家に着くまでがツアー

さて,各言語見て回ってきたけど,垣間見えた言語仕様という視点では,似ている点があるのを見て,これはどの様な経緯で導入されたのか,と言うことを比較したくなった.多分やらないだろうけど.
本題である,IRCボットを書くと言う視点では,Python, Ruby, PHPはコーディングスタイルもコードの外観も余り変わらないように感じた,と言うか事実そうだった.Perlは少々例外的.今,IRCボットを書くならどれがオススメかな?と他人に問われたら,きっと,「きみがメインとして位置づけているLLで書くと良いよ!」と答えるだろう.メインPerl以外なら.
Python, Ruby, PHPのライブラリがそれぞれ同じスタイルを採ったのは,別に遺伝子をもらったわけではなく,その環境下ではその方が見通しが良いからそうなった,という平行進化みたいなものだと思う.だからどうなんだって言うと,単に平行進化って書きたかっただけだけど,何かの機会で他の言語に移って,IRCボットを書こうと思い立ったとき,昔の言語と同じスタイルで書けるなら,きっと,それほど良いことはないだろうね.新しいスタイルを憶える労力が必要ないのだから.
結論としては,各言語とも,IRCボットを書くという点で格差は感じられず,それぞれちゃんとしたIRCボットを書くに値する環境だと思った.
Ruby以外は全くの素人なんだけど,果たしてこの結論は正しいのだろうか・・・あと,とりとめもなく書いた所為で,書いてあることがしっちゃかめっちゃかな気がしないでもない.