SVX日記
2024-12-28(Sat) WebSocketクライアントを実装する
しばらく前にMezatalkというチャットツールを作り、職場で活用している。チャットツールといえばWebSocketだ。発言の送信や受信には必須の機能である。通常、発言はブラウザのJavaScriptから行われる。が、Ruby版のコマンド「wsclient」も用意してある。ボットに発言させたい場合などに使える。こんな感じだ。
res = system('./wsclient',
'ws://127.0.0.1:33109/',
"{:REQUEST=>'login', :TYPE=>'talk', :USER=>'user1', :ROOM=>'_t~room1'}@@login",
"{:REQUEST=>'sentence'}@@Hello.",
)
実際、先日に記事にしたXalebotは、その名の通りボットであり、問い合わせに対する回答案などをMezatalkに発言するという連携機能も備わっている。
で、今回「特定の発言が行われたら、別のシステムでその発言を処理する」という機能を実装する必要が生じた。まずは、別のシステムから発言を取得できるようなAPIを実装し、WebSocket経由で発言を取得できるようにした。さらに、発言が行われた場合に、設定ファイル中に記述した関数を呼び出す機能を実装し、そこから「wsclient」を実行しようとした、のだが……それが、どうやっても動かない。普通にコマンドとして実行すれば動くのだが、設定ファイル中からだと動かない。にっちもさっちもよっちもごっちも動かない。ドハマリ。
「wsclient」は「em-websocket-client」というRubyのライブラリを使っているのだが、見よう見まねで書いたコードなので、それ以上に追求のしようがない。うーむ、こうなったら、独自に実装するか。
というわけで、RFCの6455「The WebSocket Protocol」を眺めつつ、チマチマと実装していく。HTTPで接続後、プロトコルを切り替えたり、クライアントからの送信内容にはマスクを施したり、なんかいろいろと珍しい処理がある。面倒クサいが面白い。面白いが面倒クサい。
#!/usr/bin/env ruby
require './wshelper'
require 'timeout'
require 'socket'
wsh = WebSocketHelper.new(uri = ARGV.shift)
Timeout.timeout(3) {
sock = TCPSocket.open(wsh.uri.host, wsh.uri.port)
sock.syswrite(wsh.handshake)
sock.sysread(9999)
while(request = ARGV.shift)
sock.syswrite(wsh.encode(request))
puts('[%s]' % wsh.decode(sock.sysread(9999)))
end
puts('Closed.')
}
require 'uri'
class WebSocketHelper
attr_reader :uri
def initialize(uri)
@uri = URI.parse(uri)
end
def handshake
(<<REQ % [@uri.path, @uri.host, @uri.port, make_websocket_key]).gsub(/\n/, "\r\n")
GET %s HTTP/1.1
Host: %s:%s
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Key: %s
Sec-WebSocket-Version: 13
REQ
end
def make_websocket_key
nonce = []; 4.times { nonce << rand(0xFFFFFFFF) }
@websocket_key = [nonce.pack('N*')].pack('m0')
end
def encode(req)
make_masking_key
head(req) + payload(req)
end
def make_masking_key
@masking_key = rand(0xFFFFFFFF)
end
def head(req)
head = ''
fopc = 0
fopc += (fin = 1) << 7
fopc += (opcode = 1)
head << [fopc].pack('C')
mplen = 0
mplen += (mask = 1) << 7
if((it = req.length) < 126)
mplen += it
head << [mplen].pack('C')
elsif(it < 65536)
mplen += 126
head << [mplen, it].pack('Cn')
else
mplen += 127
head << [mplen, 0, it].pack('CNN')
end
if(mask == 1)
head << [@masking_key].pack('N')
end
head
end
def payload(req0)
len0 = req0.length; req = req0.dup
req << "\x00" while(req.length % 4 != 0)
res = []; req.unpack('N*').each {|u32|
res << (u32 ^ @masking_key)
}
res.pack('N*')[0, len0]
end
def decode(res)
fopc = res.slice!(0, 1)
mplen = res.slice!(0, 1).unpack('C')[0]
if(mplen < 126 and mplen == res.length)
elsif(mplen == 126 and (res.slice!(0, 2).unpack('n')[0]) == res.length)
elsif(mplen == 127 and (res.slice!(0, 8).unpack('NN')[1]) == res.length)
else
raise('Unexpected.')
end
res
end
end
まぁ、ヤルことちゃんとヤッてない。んが、仕事はキッチリこなします。まるで、オレみたいなヤツだな。んが、やっぱり、設定ファイル中から呼ぶと動かない。にっちもさっちもよっちもごっちも動かない。な、なんでぇ?
終いにはtcpdumpでパケットまで確認してしまう。要求は出ている。んが、応答が返らない。設定ファイル中から呼んだ場合だけ。なんだこれ。いや、正確にはタイムアウトした瞬間に応答が返る。なんだこれ。なんだこれ。なんだこれ。サーバ側の問題?
サーバ側は「em-websocket」というRubyのライブラリを使っているのだが、見よう見まねで書いたコードなので、それ以上に追求のしようがない。うーむ、こうなったら、独自に実装するか……って、イヤ、それはオオゴトすぎるべ。さすがに、これまで4年近くも動いているコアの部分はイジるべきではないだろう。
it = @configs[:post_paragraph_hook] and it.call(room)
これ、connection.onmessageの延長、つまり、コールバック関数の中で動いている。そんなトコで、さらに「wsclient」で要求を出して応答を待ったって、サーバ側もそこで待っとるっちゅーねん。つまり、要求を処理するヤツが、要求を出して応答を待ってたって、応答するヤツはテメエ自身なんだから、応答が返るわけがない。ぷふゎぁ〜……。
it = @configs[:post_paragraph_hook] and Thread.new {
it.call(room)
}