先日、クリエイターズネットワークで利用している IRC ボットフレームワーク Cinch のメッセージ分割処理に対する修正パッチを投稿し、受理されました。今回はその経過をまとめてみました。

IRC とは

IRC(Internet Relay Chat)は、インターネット上でのチャットを実現するテキストベースのプロトコルです。詳しくは以下をご覧ください。

クリエイターズネットワークでは、創作・TRPGに関する活動を始めとして、どなたにでもご自由にご利用いただけるIRCサーバを提供しております。詳しくは「irc.cre.jp 系 IRC サーバ群」をご覧ください。

IRC のメッセージ長制限

RFC 1459 および RFC 2812 では、IRC メッセージの長さについて以下の規定があります。

IRCメッセージは常にCR-LF(キャリッジリターン-ラインフィード)のペアで終わる文字の列です。これらのメッセージは、最後のCR-LFも含めて512文字を超え「てはなりません」。従って、コマンドとそのパラメータに使えるのは最大510文字です。

RFC 2812 日本語訳 2.3 メッセージ(Internet Archive)」より

「512 文字」は、実際には「512 バイト」です。この長さを超えたメッセージを送信すると、例えば ngircd では「Request too long」というエラーにより接続が強制的に切断されます。そのため、IRC で非常に長いメッセージを送信する際には、適切な長さでメッセージを分割して送信しなければなりません。

Cinch のメッセージ自動分割機能

Cinch には、PRIVMSG・NOTICE 送信時に上記の規定より長いメッセージを自動的に分割する機能が以前から搭載されていました。非常に長いメッセージをボットから送信しようとすると、例えば以下のように分割されます。

(msg_split) Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit ...
(msg_split) ... anim id est laborum.

1 行目の末尾は officia deserunt mollit ... です。すなわち  ... で終わっています。2 行目は ...  で始まっています。このように、分割されたことを示す文字列を付加することができます。また、この文字列は設定により変更可能です。

分割位置は、メッセージのヘッダや付加文字列を含めて規定の文字数(≠ バイト数)を超えない最も右の位置です。ただし、それよりも左に空白が含まれている場合はその位置になります。

このメッセージ自動分割機能は Cinch::Target#send(/lib/cinch/target.rb)に含まれています。以下はコミット 9b66b21 の /lib/cinch/target.rb の抜粋です。

    def send(text, notice = false)
      # TODO deprecate `notice` argument, put splitting into own
      # method
      text = text.to_s
      split_start = @bot.config.message_split_start || ""
      split_end   = @bot.config.message_split_end   || ""
      command = notice ? "NOTICE" : "PRIVMSG"

      text.split(/\r\n|\r|\n/).each do |line|
        maxlength = 510 - (":" + " #{command} " + " :").size
        maxlength = maxlength - @bot.mask.to_s.length - @name.to_s.length
        maxlength_without_end = maxlength - split_end.bytesize

        if line.bytesize > maxlength
          splitted = []

          while line.bytesize > maxlength_without_end
            pos = line.rindex(/\s/, maxlength_without_end)
            r = pos || maxlength_without_end
            splitted << line.slice!(0, r) + split_end.tr(" ", "\u00A0")
            line = split_start.tr(" ", "\u00A0") + line.lstrip
          end

          splitted << line
          splitted[0, (@bot.config.max_messages || splitted.size)].each do |string|
            string.tr!("\u00A0", " ") # clean string from any non-breaking spaces
            @bot.irc.send("#{command} #@name :#{string}")
          end
        else
          @bot.irc.send("#{command} #@name :#{line}")
        end
      end
    end

マルチバイト文字を含む長いメッセージを送信する際に生じる問題

鯉さんとクリエイターズネットワーク公式 IRC ボットのプログラム RGRB の開発作業を行っていたとき、以下のような奇妙な動作を発見しました。

(msg_split) 私はその人を常に先生と呼んでいた。だからここでもただ先生と書くだけで本名は打ち明けない。これは世間を憚かる遠慮というよりも、その方が私にとって自然だからである。私はその人の記憶を呼び起すごとに、すぐ「先生」といいたくなる。筆を執っても心持は同じ事である。よそよそしい頭文字などはとて�[CUT]
(msg_split) ...

原文は青空文庫に掲載されている『こころ』の冒頭です。

私はその人を常に先生と呼んでいた。だからここでもただ先生と書くだけで本名は打ち明けない。これは世間を憚かる遠慮というよりも、その方が私にとって自然だからである。私はその人の記憶を呼び起すごとに、すぐ「先生」といいたくなる。筆を執っても心持は同じ事である。よそよそしい頭文字などはとても使う気にならない。

1 行目の末尾は よそよそしい頭文字などはとて�[CUT] と中途半端なところで切れています。また、2 行目には内容が含まれていません。

いくつかの文章を使って実験したところ、日本語の文章のようなマルチバイト文字を含む長いメッセージを送信した場合にこのような問題が起こることが分かりました。

原因

問題の原因は、分割位置を計算するアルゴリズムの誤りでした。具体的には、バイト数を基準として計算するべきところで、文字数を基準としていました。欧米圏で使われているシングルバイト文字のみが含まれる場合はバイト数と文字数が等しくなりますが、マルチバイト文字が含まれるとバイト数が文字数より大きくなる(例えば、UTF-8 ではひらがなやカタカナ、漢字等は 1 文字につき 3 バイト)ため、規定のバイト数に収めることができなくなってしまいます。

ソースコード上の該当部分は以下の (1)、(2) です。それぞれ String#rindexString#slice! が使われています。これらは文字数を基準として処理位置を指定するメソッドです。

while line.bytesize > maxlength_without_end
  pos = line.rindex(/\s/, maxlength_without_end) # (1)
  r = pos || maxlength_without_end
  splitted << line.slice!(0, r) + split_end.tr(" ", "\u00A0") # (2)
  line = split_start.tr(" ", "\u00A0") + line.lstrip
end

対策

メッセージをバイト数が規定以下かつ最大となる位置で分割すれば、上記の問題は発生しません。Ruby の String クラスにはそのように働くメソッドは存在しないため、この処理を実装し、pull request を送りました。処理の概要は以下のとおりです。

  1. 文字ごとに累積バイト数を求め、配列に保存する。
  2. 累積バイト数配列の中で、規定値以下でかつ最大のもの(およびそのインデックス)を選ぶ。
  3. 2. で求めた値を使い、分割位置を求める(空白がある場合の処理は上記と同じ)。

以下はコミット 6410d5e の /lib/cinch/target.rb の抜粋に注釈を付けたものです。

    def send(text, notice = false)
      text = text.to_s
      split_start = @bot.config.message_split_start || ""
      split_end   = @bot.config.message_split_end   || ""
      command = notice ? "NOTICE" : "PRIVMSG"
      max_bytesize = 510 - ":#{@bot.mask} #{command} #{@name} :".bytesize
      max_bytesize_without_end = max_bytesize - split_end.bytesize

      text.lines.map(&:chomp).each do |line|
        if line.bytesize > max_bytesize
          splitted = []

          rest = line
          while rest.bytesize > max_bytesize_without_end
            # 累積バイト数を求める
            accumulated_bytesize = line.each_char.reduce([]) do |acc, ch|
              acc << ((acc.last || 0) + ch.bytesize)
            end

            # バイト数が規定以下かつ最大となる位置を求める
            max_slice_bytesize, max_slice_length = accumulated_bytesize.
              select { |bytesize| bytesize <= max_bytesize_without_end }.
              each_with_index.
              to_a.
              last

            # max_slice_length より左にある空白の位置を求める
            last_space_index = rest.rindex(/\s/, max_slice_length)

            # 分割位置を決める
            r = if last_space_index
                  # max_slice_length より左に空白が存在する場合、最後の空白の左にする
                  accumulated_bytesize[last_space_index] - 1
                else
                  # max_slice_length より左に空白が存在しない場合、バイト数が規定以下かつ
                  # 最大となる位置にする
                  max_slice_bytesize
                end

            splitted << (rest.byteslice(0...r) +
                         split_end.tr(" ", "\u00A0"))
            rest = split_start.tr(" ", "\u00A0") +
              rest.byteslice(r...rest.bytesize).lstrip
          end
          splitted << rest

          splitted[0, (@bot.config.max_messages || splitted.size)].each do |string|
            string.tr!("\u00A0", " ") # clean string from any non-breaking spaces
            @bot.irc.send("#{command} #@name :#{string}")
          end
        else
          @bot.irc.send("#{command} #@name :#{line}")
        end
      end
    end

修正後は、メッセージが以下のように分割されます。

(msg_split) 私はその人を常に先生と呼んでいた。だからここでもただ先生と書くだけで本名は打ち明けない。これは世間を憚かる遠慮というよりも、その方が私にとって自然だからである。私はその人の記憶を呼び起すごとに、すぐ「先生」といいたくなる。筆を執っても心持は同じ事である。よそよそしい頭文字などはとても ...
(msg_split) ... 使う気にならない。

1 行目の末尾は よそよそしい頭文字などはとても ... と、2 行目は ... 使う気にならない。 となっているので、分割位置は適切です。

作者による改良と新たなバグの発生

Pull request 送信後、作者の Dominik Honnef さんから「あなたのパッチを若干簡略化しました。正しく動作するか確認していただけないでしょうか」という返信がありました。内容を見ると、累積バイト数計算の簡潔化、分割処理のメソッド化といった改良を施していただいたことが分かりました。

改良された分割処理を以下に示します。

    private
    def split_message(msg, prefix, split_start, split_end)
      max_bytesize = 510 - prefix.bytesize
      max_bytesize_without_end = max_bytesize - split_end.bytesize

      if msg.bytesize <= max_bytesize
        return [msg]
      end

      splitted = []
      acc = 0
      # (*)
      acc_rune_sizes = msg.each_char.map {|ch|
        acc += ch.bytesize
      }
      while msg.bytesize > max_bytesize_without_end
        max_rune = acc_rune_sizes.rindex {|bs| bs <= max_bytesize_without_end} || 0
        r = [msg.rindex(/\s/, max_rune) || max_rune, 1].max
        splitted << (msg[0...r] + split_end.tr(" ", "\u00A0"))
        msg = split_start.tr(" ", "\u00A0") + msg[r..-1].lstrip
      end
      splitted << msg
    end

シングルバイト文字・マルチバイト文字混在時に生じる問題

一方で、このコードには問題があります。具体的には、上記 (*) の場所で累積バイト数が求められている場合、2 箇所目以降の分割位置が正しく求められなくなる可能性があります。これは、分割単位内にバイト数の異なる文字が混在している場合、各分割単位で含まれるべき文字の数が変化する可能性があるためです。

問題が生じるメッセージの例を以下に示します(青空文庫の『坊っちゃん』冒頭を改変)。先頭にシングルバイト文字列「JAPANESE_TEXT:」があり、残りはすべてマルチバイト文字となっていることがポイントです。

JAPANESE_TEXT:親譲りの無鉄砲で小供の時から損ばかりしている。小学校に居る時分学校の二階から飛び降りて一週間ほど腰を抜かした事がある。なぜそんな無闇をしたと聞く人があるかも知れぬ。別段深い理由でもない。新築の二階から首を出していたら、同級生の一人が冗談に、いくら威張っても、そこから飛び降りる事は出来まい。弱虫やーい。と囃したからである。小使に負ぶさって帰って来た時、おやじが大きな眼をして二階ぐらいから飛び降りて腰を抜かす奴があるかと云ったから、この次は抜かさずに飛んで見せますと答えた。親類のものから西洋製のナイフを貰って奇麗な刃を日に翳して、友達に見せていたら、一人が光る事は光るが切れそうもないと云った。

この問題を防ぐには、以下のように繰り返しごとに累積バイト数を計算するようにします(コミット ecb4d1c)。

    private
    def split_message(msg, prefix, split_start, split_end)
      max_bytesize = 510 - prefix.bytesize
      max_bytesize_without_end = max_bytesize - split_end.bytesize

      if msg.bytesize <= max_bytesize
        return [msg]
      end

      splitted = []
      while msg.bytesize > max_bytesize_without_end
        # 繰り返しごとに累積バイト数を計算する
        acc = 0
        acc_rune_sizes = msg.each_char.map {|ch|
          acc += ch.bytesize
        }

        max_rune = acc_rune_sizes.rindex {|bs|
          bs <= max_bytesize_without_end
        } || 0
        r = [msg.rindex(/\s/, max_rune) || (max_rune + 1), 1].max

        splitted << (msg[0...r] + split_end)
        msg = split_start.tr(" ", "\z") + msg[r..-1].lstrip
      end
      splitted << msg

      # clean string from any substitute characters
      splitted.map {|string| string.gsub("\z", ' ')}
    end

今回の修正では、事前にコミット 764dcc4 で用意したテストコードを用いて確認を行いました。分割処理のメソッド化によりテストコードが書きやすくなっていて、大変ありがたかったです。

マージ

最後にブランチ操作で失敗し、ご迷惑をかけてしまいましたが、修正後パッチをマージしていただきました。丁寧に対応していただいた作者の Dominik さんに感謝いたします。

まとめ

IRC ボットフレームワーク Cinch のメッセージ分割処理で発生する問題を修正しました。メッセージの累積バイト数を厳密に計算することで、規定のバイト数を超えない適切な位置でメッセージを分割するように処理を変更しました。作者の Dominik Honnef さんのご協力により、処理をより簡潔にし、新たにテストコードを追加することができました。