diff --git a/Gemfile b/Gemfile index 54cb872..735ed5c 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,4 @@ source 'https://rubygems.org' gem 'cinch' gem 'cinch-dicebag' gem 'translit' +gem 'nokogiri' diff --git a/bot.rb b/bot.rb index 48b07c5..40c6311 100644 --- a/bot.rb +++ b/bot.rb @@ -1,19 +1,40 @@ require 'cinch' require 'cinch-dicebag' require 'translit' +require_relative 'plugins/history' +require_relative 'plugins/link_info' +require_relative 'plugins/help' +require_relative 'plugins/seen' bot = Cinch::Bot.new do configure do |c| c.server = "irc.forestnet.org" c.port = 6667 - c.channels = ["#urq"] + c.channels = ["#urq", "#instead", "#qsp", "#ifrus"] c.nick = 'Дроид' - c.plugins.plugins = [Cinch::Plugins::Dicebag] + c.plugins.plugins = [ + Cinch::Plugins::Dicebag, + Cinch::History, + Cinch::Help, + Cinch::LinkInfo, + Cinch::Seen + ] + c.plugins.options[Cinch::History] = { + :mode => :max_messages, + :max_messages => 20, + # :max_age => 5, + :time_format => "%H:%M", + :file => "/home/znc/seenlog.dat" + } + c.plugins.options[Cinch::LinkInfo] = { + :blacklist => [/\.xz$/i, /\.zip$/i, /\.rar$/i], + :no_description => true, + } end on :message do |m| if m.user.nick == 'MAlischka' or m.user.nick == 'MA' then - Channel('#urq').send(Translit.convert(m.message, :russian)) + m.reply( Translit.convert(m.message, :russian) ) end end end diff --git a/plugins/help.rb b/plugins/help.rb new file mode 100644 index 0000000..02c6513 --- /dev/null +++ b/plugins/help.rb @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +# +# = Cinch help plugin +# This plugin parses the +help+ option of all plugins registered with +# the bot when connecting to the server and provides a nice interface +# for querying these help strings via the 'help' command. +# +# == Commands +# [cinch help] +# Print the intro message and list all plugins. +# [cinch help ] +# List all commands with explanations for a plugin. +# [cinch help search ] +# List all commands with explanations that contain . +# +# == Dependencies +# None. +# +# == Configuration +# Add the following to your bot’s configure.do stanza: +# +# config.plugins.options[Cinch::Help] = { +# :intro => "%s at your service." +# } +# +# [intro] +# First message posted when the user issues 'help' to the bot +# without any parameters. %s is replaced with Cinch’s current +# nickname. +# +# == Writing help messages +# In order to add help strings, update your plugins to set +help+ +# properly like this: +# +# class YourPlugin +# include Cinch::Plugin +# +# set :help, <<-HELP +# command +# This command does this and that. The description may +# as well be multiline. +# command2 [optpara] +# Another sample command. +# HELP +# end +# +# Cinch recognises a command in the help string as one if it starts at +# the beginning of the line, all subsequent indented lines are considered +# part of the command explanation, up until the next line that is not +# indented or the end of the string. Multiline explanations are therefore +# handled fine, but you should still try to keep the descriptions as short +# as possible, because many IRC servers will block too much text as flooding, +# resulting in the help messages being cut. Instead, provide a web link or +# something similar for full-blown descriptions. +# +# The command itself may be in any form you want (as long as it’s a single +# line), but I recommend the following conventions so users know how to +# talk to the bot: +# +# [cinch ...] +# A command Cinch understands when posted publicely into the channel, +# prefixed with its name (or whatever prefix you want, replace "cinch" +# accordingly). +# [/msg cinch ...] +# A command Cinch understands when send directly to him via PM and +# without a prefix. +# [[/msg] cinch ...] +# A command Cinch understands when posted publicely with his nick as +# a prefix, or privately to him without any prefix. +# [...] +# That is, a bare command without a prefix. Cinch watches the conversion +# on the channel, and whenever he encounters this string/pattern he will +# take action, without any prefix at all. +# +# The word "cinch" in the command string will automatically be replaced with +# the actual nickname of your bot. +# +# == Author +# Marvin Gülker (Quintus) +# +# == License +# A help plugin for Cinch. +# Copyright © 2012 Marvin Gülker +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +# Help plugin for Cinch. +class Cinch::Help + include Cinch::Plugin + + listen_to :connect, :method => :on_connect + match /help(.*)/i, :prefix => lambda{|msg| Regexp.compile("^#{Regexp.escape(msg.bot.nick)}:?\s*")}, :react_on => :channel + match /help(.*)/i, :use_prefix => false, :react_on => :private + + set :help, <<-EOF +[/msg] cinch help + Post a short introduction and list available plugins. +[/msg] cinch help + List all commands available in a plugin. +[/msg] cinch help search + Search all plugin’s commands and list all commands containing + . + EOF + + def execute(msg, query) + query = query.strip.downcase + response = "" + + # Act depending on the subcommand. + if query.empty? + response << @intro_message.strip << "\n" + response << "Available plugins:\n" + response << bot.config.plugins.plugins.map{|plugin| format_plugin_name(plugin)}.join(", ") + response << "\n'help ' for help on a specific plugin." + + # Help for a specific plugin + elsif plugin = @help.keys.find{|plugin| format_plugin_name(plugin) == query} + @help[plugin].keys.sort.each do |command| + response << format_command(command, @help[plugin][command], plugin) + end + + # help search <...> + elsif query =~ /^search (.*)$/i + query2 = $1.strip + @help.each_pair do |plugin, hsh| + hsh.each_pair do |command, explanation| + response << format_command(command, explanation, plugin) if command.include?(query2) + end + end + + # For plugins without help + response << "Sorry, no help available for the #{format_plugin_name(plugin)} plugin." if response.empty? + + # Something we don't know what do do with + else + response << "Sorry, I cannot find '#{query}'." + end + + response << "Sorry, nothing found." if response.empty? + msg.reply(response) + end + + # Called on startup. This method iterates the list of registered plugins + # and parses all their help messages, collecting them in the @help hash, + # which has a structure like this: + # + # {Plugin => {"command" => "explanation"}} + # + # where +Plugin+ is the plugin’s class object. It also parses configuration + # options. + def on_connect(msg) + @help = {} + + if config[:intro] + @intro_message = config[:intro] % bot.nick + else + @intro_message = "#{bot.nick} at your service." + end + + bot.config.plugins.plugins.each do |plugin| + @help[plugin] = Hash.new{|h, k| h[k] = ""} + next unless plugin.help # Some plugins don't provide help + current_command = "" # For not properly formatted help strings + + plugin.help.lines.each do |line| + if line =~ /^\s+/ + @help[plugin][current_command] << line.strip + else + current_command = line.strip.gsub(/cinch/i, bot.name) + end + end + end + end + + private + + # Format the help for a single command in a nice, unicode mannor. + def format_command(command, explanation, plugin) + result = "" + + result << "┌─ " << command << " ─── Plugin: " << format_plugin_name(plugin) << " ─" << "\n" + result << explanation.lines.map(&:strip).join(" ").chars.each_slice(80).map(&:join).join("\n") + result << "\n" << "└ ─ ─ ─ ─ ─ ─ ─ ─\n" + + result + end + + # Downcase the plugin name and clip it to the last component + # of the namespace, so it can be displayed in a user-friendly + # way. + def format_plugin_name(plugin) + plugin.to_s.split("::").last.downcase + end + +end diff --git a/plugins/history.rb b/plugins/history.rb new file mode 100644 index 0000000..3fc76aa --- /dev/null +++ b/plugins/history.rb @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +# +# = Cinch history plugin +# This plugin adds a non-persistant, short-time memory to Cinch that allows +# users to replay part of the session Cinch currently observers. This plugin +# can act in two different modes: The default mode is :max_messags, in which +# Cinch will only remember a fixed number of messages for replay; however, in +# a busy channel this may not provide enough information for finding one’s way +# into a discussion without having to raise the limit of remembered messages +# to some extraordinarily high value which this plugin is not meant for (if +# you want real, persistant logging, use the Logging plugin). Instead, you +# can switch to :max_age mode, in which Cinch will remember all messages +# whose age does not exceed a certain number of minutes. +# +# == Usage +# As a user, issue the following command privately to Cinch: +# +# /msg cinch history +# +# Cinch will respond to you with a private message containing the history +# in the configured format. +# +# == Configuration +# Add the following to your bot’s configure.do stanza: +# +# config.plugins.options[Cinch::History] = { +# :mode => :max_messages, +# :max_messages => 10, +# # :max_age => 5, +# :time_format => "%H:%M" +# } +# +# [mode (:max_messages)] +# Either :max_messages or :max_age, see explanations above. +# [max_messages (10)] +# If you chose :max_messages mode, this is the number of messages +# Cinch will remember. +# [max_age (5)] +# If you chose :max_age mode, this is the maximum age in minutes +# that a message may have before Cinch drops it from his memory. +# The smallest useful value is 1, floating point values are not +# allowed. +# [time_format ("%H:%M")] +# When replaying history, Cinch prints the time stamp for each +# message next to it, in the format specified via this configuration +# option. See date(1) for possible directives. +# +# == Author +# Marvin Gülker (Quintus) +# +# == License +# A history plugin for Cinch. +# Copyright © 2012 Marvin Gülker +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +class Cinch::History + include Cinch::Plugin + + Entry = Struct.new(:time, :prefix, :message) + + listen_to :connect, :method => :on_connect + listen_to :channel, :method => :on_channel + listen_to :topic, :method => :on_topic + listen_to :away, :method => :on_away + listen_to :unaway, :method => :on_unaway + listen_to :action, :method => :on_action + listen_to :leaving, :method => :on_leaving + listen_to :join, :method => :on_join + timer 60, :method => :check_message_age + match /history/, :method => :replay, :react_on => :private, :use_prefix => false + + set :help, <<-HELP +/msg cinch history + Sends the most recent messages of the channel to you via PM. + HELP + + def on_connect(*) + @mode = config[:mode] || :max_messages + @max_messages = config[:max_messages] || 10 + @max_age = (config[:max_age] || 5 ) * 60 + @timeformat = config[:time_format] || "%H:%M" + @history_mutex = Mutex.new + @history = [] + end + + def on_channel(msg) + return if msg.message.start_with?("\u0001") + + @history_mutex.synchronize do + @history << Entry.new(msg.time, msg.user.nick, msg.message) + + if @mode == :max_messages + # In :max_messages mode, let messages over the limit just + # fall out of the history. + @history.shift if @history.length > @max_messages + end + end + end + + def on_topic(msg) + @history_mutex.synchronize do + @history << Entry.new(msg.time, "##", "#{msg.user.nick} changed the topic to “#{msg.channel.topic}”") + end + end + + def on_away(msg) + @history_mutex.synchronize do + @history << Entry.new(msg.time, "##", "#{msg.user.nick} is away (“#{msg.message}”)") + end + end + + def on_unaway(msg) + @history_mutex.synchronize do + @history << Entry.new(msg.time, "##" "#{msg.user.nick} is back") + end + end + + def on_action(msg) + return unless msg.message =~ /^\u0001ACTION(.*?)\u0001/ + + @history_mutex.synchronize do + @history << Entry.new(msg.time, "**", "#{msg.user.nick} #{$1.strip}") + end + end + + def on_leaving(msg, user) + @history_mutex.synchronize do + @history << Entry.new(msg.time, "<=", "#{user.nick} left the channel") + end + end + + def on_join(msg) + @history_mutex.synchronize do + @history << Entry.new(msg.time, "=>", "#{msg.user.nick} entered the channel") + end + end + + # In :max_age mode, remove messages from the history older than + # the threshold. + def check_message_age + return unless @mode == :max_age + + @history_mutex.synchronize do + @history.delete_if{|entry| Time.now - entry.time > @max_age} + end + end + + def replay(msg) + # Informative preamble + if @mode == :max_age + msg.reply("Here are the messages of the last #@max_age seconds:") + else + msg.reply("Here are the last #{@history.count} messages:") + end + + # Actual historic(al) response + @history_mutex.synchronize do + r = @history.reduce("") do |answer, entry| + answer + format_entry(entry) + end + + msg.reply(r.chomp) + end + end + + private + + def format_entry(entry) + sprintf("[%s] %15s | %s\n", entry.time.strftime(@timeformat), entry.prefix, entry.message) + end + +end diff --git a/plugins/link_info.rb b/plugins/link_info.rb new file mode 100644 index 0000000..bd29ab5 --- /dev/null +++ b/plugins/link_info.rb @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# +# = Cinch Link Info plugin +# Inspects any links that are posted into a channel Cinch +# is currently in and prints out the value of the title +# and description meta tags, if any. +# +# == Dependencies +# * Gem: nokogiri +# +# == Configuration +# Add the following to your bot’s configure.do stanza: +# +# config.plugins.options[Cinch::LinkInfo] = { +# :blacklist => [/\.xz$/], +# :no_description => false, +# } +# +# [blacklist] +# If a URL matches any of the regular expressions defined +# in this array, it will not be inspected. This plugin +# alraedy ignores URLs ending in common image file +# extensions, so you don’t have to specify .png, .jpeg, +# etc. +# [no_description] +# Set this to true if you want Cinch to not print the +# content of the Meta description tag. +# +# == Author +# Marvin Gülker (Quintus) +# +# == License +# A link info plugin for Cinch. +# Copyright © 2012 Marvin Gülker +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +require "open-uri" +require "nokogiri" + +# Plugin for inspecting links pasted into channels. +class Cinch::LinkInfo + include Cinch::Plugin + + # Default list of URL regexps to ignore. + DEFAULT_BLACKLIST = [/\.png$/i, /\.jpe?g$/i, /\.bmp$/i, /\.gif$/i, /\.pdf$/i].freeze + + set :help, <<-HELP +http[s]://... + I’ll fire a GET request at any link I encounter, parse the HTML + meta tags, and paste the result back into the channel. + HELP + + match %r{(https?://.*?)(?:\s|$|,|\.\s|\.$)}, :use_prefix => false + + def execute(msg, url) + blacklist = DEFAULT_BLACKLIST.dup + blacklist.concat(config[:blacklist]) if config[:blacklist] + + return if blacklist.any?{|entry| url =~ entry} + debug "URL matched: #{url}" + html = Nokogiri::HTML(open(url)) + + if node = html.at_xpath("html/head/title") + msg.reply("Title: #{node.text}") + end + + if !config[:no_description] + node = html.at_xpath('html/head/meta[@name="description"]') || html.at_xpath('html/head/meta[@name="Description"]') + if node + if node[:content].chars.count > 255 + msg.reply("Description: #{node[:content].lines.first(3).join("").gsub("\n", " ")[0..255] + "..."}") + else + msg.reply("Description: #{node[:content]}") + end + end + end + rescue => e + error "#{e.class.name}: #{e.message}" + end + +end diff --git a/plugins/seen.rb b/plugins/seen.rb new file mode 100644 index 0000000..92fb6dc --- /dev/null +++ b/plugins/seen.rb @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# +# = Cinch Seen plugin +# +# == Configuration +# Add the following to your bot’s configure.do stanza: +# +# config.plugins.options[Cinch::History] = { +# :file => "/var/cache/seenlog.dat" +# } +# +# [file] +# Where to store the message log. This is a required +# argument. +# +# == Author +# Marvin Gülker (Quintus) +# +# == License +# An seen info plugin for Cinch. +# Copyright © 2014 Marvin Gülker +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +class Cinch::Seen + include Cinch::Plugin + + SeenInfo = Struct.new(:time, :nick, :message) + + match /seen (.*)/ + listen_to :connect, :method => :on_connect + listen_to :channel, :method => :on_channel + + def on_connect(*) + @filepath = config[:file] || raise("Missing required argument: :file") + @file = File.open(@filepath, "a+") + @file.seek(0, File::SEEK_END) # Work around Ruby bug https://bugs.ruby-lang.org/issues/10039 + @filemutex = Mutex.new + + at_exit{@file.close} + end + + def on_channel(msg) + return if msg.message.start_with?("\u0001") # ACTION + + @filemutex.synchronize do + @file.puts("#{msg.time.to_i}\0#{msg.user.nick}\0#{msg.message.strip}") + end + end + + def execute(msg, nick) + if nick == bot.nick + msg.reply "Self-reference err err tilt BOOOOM" + return + end + + if info = find_last_message(nick) + msg.reply("I have last seen #{nick} on #{info.time} saying: #{info.message}") + else + msg.reply("I have not yet seen #{nick} saying something.") + end + end + + private + + def find_last_message(nick) + @filemutex.synchronize do + @file.rewind + @file.lines.reverse_each do |line| + parts = line.split("\0") + + if parts[1] == nick + return SeenInfo.new(Time.at(parts[0].to_i), parts[1], parts[2]) + end + end + + @file.seek(0, File::SEEK_END) + end + + nil + end + +end