From 1c400f075467072d79cea8de80f1249310d45621 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 10:53:02 +0000 Subject: [PATCH] Add IRC chatbot example using RiveScript. Introduce eg/irc-bot, a stdlib IRC adapter that loads the sample brain and replies to nick mentions and private messages, with tests against a fake IRC server. Co-authored-by: LilOleByte --- Changes.md | 1 + eg/irc-bot/README.md | 75 +++++++++ eg/irc-bot/bot.rb | 363 +++++++++++++++++++++++++++++++++++++++++++ test/test_irc_bot.rb | 279 +++++++++++++++++++++++++++++++++ 4 files changed, 718 insertions(+) create mode 100644 eg/irc-bot/README.md create mode 100755 eg/irc-bot/bot.rb create mode 100644 test/test_irc_bot.rb diff --git a/Changes.md b/Changes.md index a44d666..491e3d8 100644 --- a/Changes.md +++ b/Changes.md @@ -4,6 +4,7 @@ - Add Ruby `docs/` tree mirrored from rivescript-js docs (Ruby API only) - Add security auditing: bundler-audit + RuboCop Security cops (`rake security`) +- Add `eg/irc-bot` example: RiveScript chatbot over IRC (stdlib sockets) ## 0.1.2 diff --git a/eg/irc-bot/README.md b/eg/irc-bot/README.md new file mode 100644 index 0000000..b9ef790 --- /dev/null +++ b/eg/irc-bot/README.md @@ -0,0 +1,75 @@ +# IRC Chatbot Example + +A RiveScript bot that speaks IRC. It uses only the Ruby standard library +(`socket` / `openssl`) — no extra gems — and loads replies from `../brain/`. + +The bot joins a channel and answers: + +* Channel messages that start with its nick (`rsbot: hello` or `@rsbot hello`) +* Direct private messages + +Each IRC nick is used as the RiveScript username, so the brain can remember +per-user variables (name, age, and so on) across the session. + +## Usage + +From the root of the rivescript-rb repo: + +```bash +$ ruby eg/irc-bot/bot.rb +``` + +Or from this directory: + +```bash +$ ruby bot.rb +``` + +By default the bot connects to `127.0.0.1:6667` as `rsbot` and joins +`#rivescript`. Point it at a real network with environment variables: + +```bash +$ IRC_HOST=irc.libera.chat IRC_PORT=6697 IRC_TLS=1 \ + IRC_NICK=rsbot IRC_CHANNEL=#botters-test ruby bot.rb +``` + +Then in the channel: + +``` + rsbot: Hello bot. + Hi. What seems to be your problem? + rsbot: my name is Alice + Alice, nice to meet you. +``` + +Private messages are answered without needing the nick prefix. Type `/quit` in +your IRC client to leave; send `SIGINT` (`Ctrl-C`) to the bot process to +disconnect cleanly. + +## Configuration + +| Variable | Default | Meaning | +| --- | --- | --- | +| `IRC_HOST` | `127.0.0.1` | IRC server hostname | +| `IRC_PORT` | `6667` | IRC server port (`6697` is typical for TLS) | +| `IRC_NICK` | `rsbot` | Nickname | +| `IRC_CHANNEL` | `#rivescript` | Channel to join (`#` is added if omitted) | +| `IRC_PASSWORD` | _(none)_ | Server password (`PASS`) | +| `IRC_NICKSERV_PASSWORD` | _(none)_ | Sent as `IDENTIFY` to NickServ after welcome | +| `IRC_TLS` | unset | Set to `1` to wrap the socket in TLS | +| `IRC_ALWAYS` | unset | Set to `1` to reply to every channel message, not only mentions | +| `IRC_BRAIN` | `../brain` | Directory of `.rive` files | + +`ruby bot.rb --help` prints the same table. + +TLS uses certificate verification (`VERIFY_PEER`). This example does not +implement SASL; NickServ identify is the optional auth path. + +## Notes + +* Object macros in the sample brain are loaded. Only point `IRC_BRAIN` at + brains you trust. +* Replies are split on newlines and wrapped to stay under IRC's 512-byte line + limit. +* This is a teaching adapter, not a full IRC client: no SASL, no multi-channel + config file, no flood throttle. diff --git a/eg/irc-bot/bot.rb b/eg/irc-bot/bot.rb new file mode 100755 index 0000000..e94db82 --- /dev/null +++ b/eg/irc-bot/bot.rb @@ -0,0 +1,363 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# RiveScript-RB +# +# IRC chatbot example. +# +# Connects to an IRC server with stdlib sockets (no extra gems), loads the +# sample brain, and replies when: +# * someone starts a channel message with the bot nick ("rsbot: hello") +# * someone @mentions the bot ("@rsbot hello") +# * someone sends a private message +# +# Run: ruby bot.rb +# Configure with IRC_HOST, IRC_PORT, IRC_NICK, IRC_CHANNEL, IRC_TLS, etc. +# See README.md. + +require "socket" +require "openssl" +require_relative "../../lib/rivescript" + +$stdout.sync = true +$stderr.sync = true + +class IRCChatBot + # Stay under the RFC 1459 512-byte line limit, including "PRIVMSG target :". + MAX_PRIVMSG = 400 + + attr_reader :host, :port, :nick, :channel, :brain_path, :always, :tls + + def initialize(opts = {}) + @host = opts.fetch(:host, env("IRC_HOST", "127.0.0.1")) + @port = Integer(opts.fetch(:port, env("IRC_PORT", "6667"))) + @nick = opts.fetch(:nick, env("IRC_NICK", "rsbot")) + raw_channel = opts.fetch(:channel, env("IRC_CHANNEL", "#rivescript")) + @channel = normalize_channel(raw_channel) + @password = opts.key?(:password) ? opts[:password] : ENV["IRC_PASSWORD"] + @password = nil if @password.to_s.empty? + @nickserv_password = opts.key?(:nickserv_password) ? opts[:nickserv_password] : ENV["IRC_NICKSERV_PASSWORD"] + @nickserv_password = nil if @nickserv_password.to_s.empty? + @always = opts.key?(:always) ? opts[:always] : (ENV["IRC_ALWAYS"].to_s == "1") + @tls = opts.key?(:tls) ? opts[:tls] : (ENV["IRC_TLS"].to_s == "1") + @brain_path = opts.fetch(:brain) { ENV["IRC_BRAIN"] } + @brain_path = File.expand_path("../brain", __dir__) if @brain_path.to_s.empty? + @realname = opts.fetch(:realname, "RiveScript.rb IRC bot") + @socket = opts[:socket] + @rs = opts[:rivescript] + @logger = opts.fetch(:logger, $stdout) + @running = false + @joined = false + @buf = +"" + end + + def load_brain + return self if @rs + + @rs = RiveScript.new(debug: false, concat: "newline") + @rs.load_directory(@brain_path) + @rs.sort_replies + log "Loaded brain from #{@brain_path}" + self + end + + def run + load_brain + connect if @socket.nil? + register + @running = true + while @running + line = read_line + break if line.nil? + + handle_line(line) + end + ensure + shutdown + end + + def stop + @running = false + close_socket + end + + # Parse one IRC protocol line into prefix, command, and params. + # Trailing parameter (after " :") is the last element of params. + def self.parse_line(line) + line = line.to_s.sub(/\r?\n\z/, "") + prefix = nil + rest = line + if rest.start_with?(":") + prefix, rest = rest[1..].split(" ", 2) + rest = rest.to_s + end + + if (idx = rest.index(" :")) + middle = rest[0...idx] + trailing = rest[(idx + 2)..] + params = middle.split(" ") + command = params.shift + params << trailing.to_s + else + params = rest.split(" ") + command = params.shift + end + + { + prefix: prefix, + command: command.to_s.upcase, + params: params + } + end + + def self.nick_from_prefix(prefix) + return nil if prefix.nil? || prefix.empty? + return nil unless prefix.include?("!") + + prefix.split("!", 2).first + end + + def addressed?(target, text) + return true if private_target?(target) + return true if @always + + stripped = text.to_s.lstrip + nick_re = Regexp.escape(@nick) + stripped.match?(/\A@?#{nick_re}\b/i) + end + + def strip_address(text) + nick_re = Regexp.escape(@nick) + text.to_s.sub(/\A\s*@?#{nick_re}\s*[:,-]?\s*/i, "") + end + + def reply_target(sender, target) + private_target?(target) ? sender : target + end + + def handle_line(line) + log "<<< #{line}" unless line.empty? + msg = self.class.parse_line(line) + return if msg[:command].empty? + + case msg[:command] + when "PING" + send_raw("PONG :#{msg[:params].last}") + when "001" + identify_nickserv + join_channel + when "376", "422" + join_channel unless @joined + when "433" + @nick = "#{@nick}_" + send_raw("NICK #{@nick}") + when "PRIVMSG" + handle_privmsg(msg) + when "ERROR" + log "Server error: #{msg[:params].join(' ')}" + stop + end + end + + def handle_privmsg(msg) + sender = self.class.nick_from_prefix(msg[:prefix]) + target = msg[:params][0].to_s + text = msg[:params][1].to_s + return if sender.nil? || sender.casecmp?(@nick) + return if ctcp?(text) + return unless addressed?(target, text) + + message = strip_address(text) + return if message.strip.empty? + + dest = reply_target(sender, target) + begin + reply = @rs.reply(sender, message) + rescue StandardError => e + log "RiveScript error: #{e.message}" + send_privmsg(dest, "Sorry, I had trouble answering that.") + return + end + + send_privmsg(dest, reply) + end + + def send_privmsg(target, text) + text.to_s.split(/\r?\n/).each do |line| + next if line.strip.empty? + + wrap_line(line, MAX_PRIVMSG).each do |chunk| + send_raw("PRIVMSG #{target} :#{chunk}") + end + end + end + + def send_raw(command) + return if @socket.nil? || @socket.closed? + + @socket.write("#{command}\r\n") + log ">>> #{redact(command)}" + end + + private + + def env(name, default) + value = ENV[name] + value.nil? || value.empty? ? default : value + end + + def normalize_channel(name) + name = name.to_s + name.start_with?("#", "&") ? name : "##{name}" + end + + def private_target?(target) + !target.start_with?("#", "&") + end + + def ctcp?(text) + text.start_with?("\x01") + end + + def wrap_line(text, max) + return [text] if text.length <= max + + chunks = [] + remaining = text + until remaining.empty? + if remaining.length <= max + chunks << remaining + break + end + window = remaining[0, max] + break_at = window.rindex(" ") || max + chunk = remaining[0, break_at].strip + chunks << chunk unless chunk.empty? + remaining = remaining[break_at..].to_s.lstrip + end + chunks + end + + def connect + tcp = TCPSocket.new(@host, @port) + if @tls + ctx = OpenSSL::SSL::SSLContext.new + ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER) + ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx) + ssl.hostname = @host + ssl.sync_close = true + ssl.connect + @socket = ssl + else + @socket = tcp + end + log "Connected to #{@host}:#{@port}#{' (TLS)' if @tls}" + end + + def register + send_raw("PASS #{@password}") if @password + send_raw("NICK #{@nick}") + send_raw("USER #{@nick} 0 * :#{@realname}") + end + + def identify_nickserv + return unless @nickserv_password + + send_raw("PRIVMSG NickServ :IDENTIFY #{@nickserv_password}") + end + + def join_channel + return if @joined + + send_raw("JOIN #{@channel}") + @joined = true + log "Joining #{@channel} as #{@nick}" + end + + def read_line + loop do + if (idx = @buf.index("\n")) + line = @buf.slice!(0..idx) + return line.sub(/\r?\n\z/, "") + end + + chunk = @socket.readpartial(4096) + @buf << chunk + rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE + return nil + end + end + + def redact(command) + case command + when /\APASS /i + "PASS ********" + when /\APRIVMSG NickServ :IDENTIFY /i + "PRIVMSG NickServ :IDENTIFY ********" + else + command + end + end + + def log(message) + @logger.puts(message) if @logger + end + + def close_socket + @socket.close unless @socket.nil? || @socket.closed? + rescue IOError, Errno::EBADF + # already closed + end + + def shutdown + return if @socket.nil? || @socket.closed? + + send_raw("QUIT :RiveScript.rb") + close_socket + rescue IOError, Errno::EPIPE, Errno::ECONNRESET + close_socket + end +end + +if $PROGRAM_NAME == __FILE__ + if ARGV.include?("-h") || ARGV.include?("--help") + puts <<~HELP + RiveScript IRC chatbot + + Usage: + ruby #{File.basename($PROGRAM_NAME)} + + Environment: + IRC_HOST server hostname (default: 127.0.0.1) + IRC_PORT server port (default: 6667) + IRC_NICK bot nickname (default: rsbot) + IRC_CHANNEL channel to join (default: #rivescript) + IRC_PASSWORD optional server password + IRC_NICKSERV_PASSWORD optional NickServ password + IRC_TLS set to 1 to use TLS (typically port 6697) + IRC_ALWAYS set to 1 to reply to every channel message + IRC_BRAIN path to a RiveScript brain (default: ../brain) + + Example: + IRC_HOST=irc.libera.chat IRC_PORT=6697 IRC_TLS=1 \\ + IRC_NICK=rsbot IRC_CHANNEL=#botters-test ruby bot.rb + + In a channel, address the bot: + rsbot: hello + @rsbot what is your name? + HELP + exit 0 + end + + bot = IRCChatBot.new + %w[INT TERM].each do |sig| + trap(sig) { bot.stop } + end + + begin + bot.run + rescue StandardError => e + warn "Failed: #{e.message}" + exit 1 + end +end diff --git a/test/test_irc_bot.rb b/test/test_irc_bot.rb new file mode 100644 index 0000000..9b42712 --- /dev/null +++ b/test/test_irc_bot.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +require "socket" +require "stringio" +require "timeout" +require_relative "helper" +require_relative "../eg/irc-bot/bot" + +class TestIrcBot < Minitest::Test + def test_parse_line_with_prefix_and_trailing + msg = IRCChatBot.parse_line(":alice!a@host PRIVMSG #rivescript :rsbot: hello there") + assert_equal "alice!a@host", msg[:prefix] + assert_equal "PRIVMSG", msg[:command] + assert_equal ["#rivescript", "rsbot: hello there"], msg[:params] + end + + def test_parse_line_ping + msg = IRCChatBot.parse_line("PING :irc.example.net") + assert_nil msg[:prefix] + assert_equal "PING", msg[:command] + assert_equal ["irc.example.net"], msg[:params] + end + + def test_parse_line_strips_crlf + msg = IRCChatBot.parse_line(":server 001 rsbot :Welcome\r\n") + assert_equal "001", msg[:command] + assert_equal ["rsbot", "Welcome"], msg[:params] + end + + def test_nick_from_prefix + assert_equal "alice", IRCChatBot.nick_from_prefix("alice!user@host") + assert_nil IRCChatBot.nick_from_prefix("irc.example.net") + assert_nil IRCChatBot.nick_from_prefix(nil) + end + + def test_addressed_channel_mentions_and_private_messages + bot = IRCChatBot.new(nick: "rsbot", channel: "#test", rivescript: :unused, logger: nil) + + assert bot.addressed?("rsbot", "hello") + assert bot.addressed?("#test", "rsbot: hello") + assert bot.addressed?("#test", "RsBot, hello") + assert bot.addressed?("#test", "@rsbot hello") + refute bot.addressed?("#test", "hello everyone") + refute bot.addressed?("#test", "notabot: hi") + end + + def test_always_replies_in_channel + bot = IRCChatBot.new(nick: "rsbot", channel: "#test", always: true, rivescript: :unused, logger: nil) + assert bot.addressed?("#test", "hello everyone") + end + + def test_strip_address + bot = IRCChatBot.new(nick: "rsbot", rivescript: :unused, logger: nil) + assert_equal "hello", bot.strip_address("rsbot: hello") + assert_equal "hello", bot.strip_address("@rsbot hello") + assert_equal "hello", bot.strip_address("RsBot, hello") + assert_equal "hello everyone", bot.strip_address("hello everyone") + end + + def test_reply_target_private_vs_channel + bot = IRCChatBot.new(nick: "rsbot", rivescript: :unused, logger: nil) + assert_equal "alice", bot.reply_target("alice", "rsbot") + assert_equal "#test", bot.reply_target("alice", "#test") + end + + def test_redacts_passwords_in_logs + logs = StringIO.new + rs = RiveScript.new + rs.stream("+ hello\n- Hi human.\n") + rs.sort_replies + client, server = UNIXSocket.pair + bot = IRCChatBot.new( + nick: "rsbot", + channel: "#test", + password: "s3cret", + nickserv_password: "nickpass", + rivescript: rs, + socket: client, + logger: logs + ) + + bot.send(:register) + bot.send(:identify_nickserv) + + output = logs.string + refute_includes output, "s3cret" + refute_includes output, "nickpass" + assert_match(/PASS \*+/, output) + assert_match(/IDENTIFY \*+/, output) + + from_bot = server.readpartial(4096) + assert_includes from_bot, "PASS s3cret" + assert_includes from_bot, "IDENTIFY nickpass" + ensure + client&.close + server&.close + end + + def test_welcome_triggers_join + logs = StringIO.new + rs = stub_brain + client, server = UNIXSocket.pair + bot = IRCChatBot.new(nick: "rsbot", channel: "#rivescript", rivescript: rs, socket: client, logger: logs) + + bot.handle_line(":irc.example 001 rsbot :Welcome") + assert_equal "JOIN #rivescript\r\n", server.readpartial(64) + ensure + client&.close + server&.close + end + + def test_ping_gets_pong + logs = StringIO.new + rs = stub_brain + client, server = UNIXSocket.pair + bot = IRCChatBot.new(nick: "rsbot", rivescript: rs, socket: client, logger: logs) + + bot.handle_line("PING :irc.example.net") + assert_equal "PONG :irc.example.net\r\n", server.readpartial(64) + ensure + client&.close + server&.close + end + + def test_channel_mention_gets_rivescript_reply + logs = StringIO.new + rs = stub_brain + client, server = UNIXSocket.pair + bot = IRCChatBot.new(nick: "rsbot", channel: "#test", rivescript: rs, socket: client, logger: logs) + + bot.handle_line(":alice!a@host PRIVMSG #test :rsbot: hello") + reply = server.readpartial(256) + assert_match(/\APRIVMSG #test :Hi human\.\r\n\z/, reply) + ensure + client&.close + server&.close + end + + def test_unaddressed_channel_message_is_ignored + logs = StringIO.new + rs = stub_brain + client, server = UNIXSocket.pair + bot = IRCChatBot.new(nick: "rsbot", channel: "#test", rivescript: rs, socket: client, logger: logs) + + bot.handle_line(":alice!a@host PRIVMSG #test :hello everyone") + assert_nil IO.select([server], nil, nil, 0.05) + ensure + client&.close + server&.close + end + + def test_private_message_gets_reply_without_prefix + logs = StringIO.new + rs = stub_brain + client, server = UNIXSocket.pair + bot = IRCChatBot.new(nick: "rsbot", rivescript: rs, socket: client, logger: logs) + + bot.handle_line(":alice!a@host PRIVMSG rsbot :hello") + reply = server.readpartial(256) + assert_match(/\APRIVMSG alice :Hi human\.\r\n\z/, reply) + ensure + client&.close + server&.close + end + + def test_ignores_own_messages_and_ctcp + logs = StringIO.new + rs = stub_brain + client, server = UNIXSocket.pair + bot = IRCChatBot.new(nick: "rsbot", channel: "#test", rivescript: rs, socket: client, logger: logs) + + bot.handle_line(":rsbot!b@host PRIVMSG #test :rsbot: hello") + bot.handle_line(":alice!a@host PRIVMSG #test :\x01VERSION\x01") + assert_nil IO.select([server], nil, nil, 0.05) + ensure + client&.close + server&.close + end + + def test_multiline_reply_is_split + logs = StringIO.new + rs = RiveScript.new + rs.stream("+ hello\n- line one\\nline two\n") + rs.sort_replies + client, server = UNIXSocket.pair + bot = IRCChatBot.new(nick: "rsbot", rivescript: rs, socket: client, logger: logs) + + bot.handle_line(":alice!a@host PRIVMSG rsbot :hello") + body = read_available(server) + assert_includes body, "PRIVMSG alice :line one\r\n" + assert_includes body, "PRIVMSG alice :line two\r\n" + ensure + client&.close + server&.close + end + + def test_live_handshake_against_fake_irc_server + rs = stub_brain + server = TCPServer.new("127.0.0.1", 0) + port = server.local_address.ip_port + seen = Queue.new + + server_thread = Thread.new do + client = server.accept + client.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if defined?(Socket::IPPROTO_TCP) + buf = +"" + Timeout.timeout(5) do + loop do + buf << client.readpartial(4096) + while (idx = buf.index("\n")) + line = buf.slice!(0..idx).sub(/\r?\n\z/, "") + seen << line + parsed = IRCChatBot.parse_line(line) + case parsed[:command] + when "NICK" + client.write(":irc.example 001 rsbot :Welcome\r\n") + when "JOIN" + client.write(":alice!a@host PRIVMSG #rivescript :rsbot: hello\r\n") + when "PRIVMSG" + Thread.exit + end + end + end + end + ensure + client&.close + end + + logs = StringIO.new + bot = IRCChatBot.new( + host: "127.0.0.1", + port: port, + nick: "rsbot", + channel: "#rivescript", + rivescript: rs, + logger: logs + ) + bot_thread = Thread.new { bot.run } + + commands = [] + Timeout.timeout(5) do + loop do + line = seen.pop + commands << line + break if line.start_with?("PRIVMSG #rivescript :Hi human.") + end + end + + assert commands.any? { |l| l.start_with?("NICK ") } + assert commands.any? { |l| l.start_with?("USER ") } + assert commands.any? { |l| l.start_with?("JOIN #rivescript") } + assert commands.any? { |l| l.start_with?("PRIVMSG #rivescript :Hi human.") } + ensure + bot&.stop + begin + server&.close + rescue IOError + nil + end + bot_thread&.join(1) + server_thread&.join(1) + end + + private + + def stub_brain + rs = RiveScript.new + rs.stream("+ hello\n- Hi human.\n") + rs.sort_replies + rs + end + + def read_available(socket, wait = 0.2) + chunks = [] + chunks << socket.readpartial(4096) while IO.select([socket], nil, nil, wait) + chunks.join + end + +end