# Defines the Autumn::Leaf class, a library on which robust IRC bots can be # written. require 'yaml' require 'timeout' require 'erb' require 'autumn/formatting' module Autumn # This is the superclass that all Autumn leaves use. To write a leaf, sublcass # this class and implement methods for each of your leaf's commands. Your # leaf's repertoire of commands is derived from the names of the methods you # write. For instance, to have your leaf respond to a "!hello" command in IRC, # write a method like so: # # def hello_command(stem, sender, reply_to, msg) # stem.message "Why hello there!", reply_to # end # # You can also implement this method as: # # def hello_command(stem, sender, reply_to, msg) # return "Why hello there!" # end # # Methods of the form <tt>[word]_command</tt> tell the leaf to respond to # commands in IRC of the form "![word]". They should accept four parameters: # # 1. the Stem that received the message, # 2. the sender hash for the person who sent the message (see below), # 3. the "reply-to" string (either the name of the channel that the command # was typed on, or the nick of the person that whispered the message), and # 4. any text following the command. For instance, if the person typed "!eat A # tasty slice of pizza", the last parameter would be "A tasty slice of # pizza". This is nil if no text was supplied with the command. # # <b>Sender hashes:</b> A "sender hash" is a hash with the following keys: # +nick+ (the user's nickname), +user+ (the user's username), and +host+ (the # user's hostname). Any of these fields except +nick+ could be nil. Sender # hashes are used throughout the Stem and Leaf classes, as well as other # classes; they always have the same keys. # # If your <tt>*_command</tt> method returns a string, it will be sent as an # IRC message to "reply-to" parameter.If your leaf needs to respond to more # complicated commands, you will have to override the # did_receive_channel_message method (see below). If you like, you can remove # the quit_command method in your subclass, for instance, to prevent the leaf # from responding to !quit. You can also protect that method using filters # (see "Filters"). # # If you want to separate view logic from the controller, you can use ERb to # template your views. See the render method for more information. # # = Hook Methods # # Aside from adding your own <tt>*_command</tt>-type methods, you should # investigate overriding the "hook" methods, such as will_start_up, # did_start_up, did_receive_private_message, did_receive_channel_message, etc. # There's a laundry list of so-named methods you can override. Their default # implementations do nothing, so there's no need to call +super+. # # = Stem Convenience Methods # # Most of the IRC actions (such as joining and leaving a channel, setting a # topic, etc.) are part of a Stem object. If your leaf is only running off # of one stem, you can call these stem methods directly, as if they were # methods in the Leaf class. Otherwise, you will need to specify which stem # to perform these IRC actions on. Usually, the stem is given to you, as a # parameter for your <tt>*_command</tt> method, for instance. # # For the sake of convenience, you can make Stem method calls on the +stems+ # attribute; these calls will be forwarded to every stem in the +stems+ # attribute. For instance, to broadcast a message to all servers and all # channels: # # stems.message "Ready for orders!" # # = Filters # # Like Ruby on Rails, you can add filters to each of your commands to be # executed before or after the command is run. You can do this using the # before_filter and after_filter methods, just like in Rails. Filters are run # in the order they are added to the chain. Thus, if you wanted to run your # preload filter before you ran your cache filter, you'd write the calls in # this order: # # class MyLeaf < Leaf # before_filter :my_preload # before_filter :my_cache # end # # See the documentation for the before_filter and after_filter methods and the # README file for more information on filters. # # = Authentication # # If a leaf is initialized with a hash for the +authentication+ option, the # values of that hash are used to choose an authenticator that will be run # before each command. This authenticator will determine whether or not the # user can run that command. The options that can be specified in this hash # are: # # +type+:: The name of a class in the Autumn::Authentication module, in # snake_case. Thus, if you wanted to use the # Autumn::Authentication::Password class, which does password-based # authentication, you'd set this value to +password+. # +only+:: A list of protected commands for which authentication is required; # all other commands are unprotected. # +except+:: A list of unprotected commands; all other commands require # authentication. # +silent+:: Normally, when someone fails to authenticate himself before # running a protected command, the leaf responds with an error # message (e.g., "You have to authenticate with a password first"). # Set this to true to suppress this behaivor. # # In addition, you can also specify any custom options for your authenticator. # These options are passed to the authenticator's initialize method. See the # classes in the Autumn::Authentication module for such options. # # If you annotate a command method as protected, the authenticator will be run # unconditionally, regardless of the +only+ or +except+ options: # # class Controller < Autumn::Leaf # def destructive_command(stem, sender, reply_to, msg) # # ... # end # ann :destructive_command, :protected => true # end # # = Logging # # Autumn comes with a framework for logging as well. It's very similar to the # Ruby on Rails logging framework. To log an error message: # # logger.error "Quiz data is missing!" # # By default the logger will only log +info+ events and above in production # seasons, and will log all messages for debug seasons. (See the README for # more on seasons.) To customize the logger, and for more information on # logging, see the LogFacade class documentation. # # = Colorizing and Formatting Text # # The Autumn::Formatting module contains sub-modules which handle formatting # for different clients (such as mIRC-style formatting, the most common). The # specific formatting module that's included depends on the leaf's # initialization options; see initialize. class Leaf include Anise::Annotation # Default for the +command_prefix+ init option. DEFAULT_COMMAND_PREFIX = '!' @@view_alias = Hash.new { |h,k| k } # The LogFacade instance for this leaf. attr :logger # The Stem instances running this leaf. attr :stems # The configuration for this leaf. attr :options # Instantiates a leaf. This is generally handled by the Foliater class. # Valid options are: # # +command_prefix+:: The string that must precede all command names (default # "!") # +responds_to_private_messages+:: If true, the bot responds to known # commands sent in private messages. # +logger+:: The LogFacade instance for this leaf. # +database+:: The name of a custom database connection to use. # +formatter+:: The name of an Autumn::Formatting class to use as the # formatter (chooses Autumn::Formatting::DEFAULT by default). # # As well as any user-defined options you want. def initialize(opts={}) @port = opts[:port] @options = opts @options[:command_prefix] ||= DEFAULT_COMMAND_PREFIX @break_flag = false @logger = options[:logger] @stems = Set.new # Let the stems array respond to methods as if it were a single stem class << @stems def method_missing(meth, *args) if all? { |stem| stem.respond_to? meth } then collect { |stem| stem.send(meth, *args) } else super end end end end def preconfigure # :nodoc: if options[:authentication] then @authenticator = Autumn::Authentication.const_get(options[:authentication]['type'].camelcase).new(options[:authentication].rekey(&:to_sym)) stems.add_listener @authenticator end end # Simplifies method calls for one-stem leaves. def method_missing(meth, *args) # :nodoc: if stems.size == 1 and stems.only.respond_to? meth then stems.only.send meth, *args else super end end ########################## METHODS INVOKED BY STEM ######################### def stem_ready(stem) # :nodoc: return unless Thread.exclusive { stems.ready?.all? } database { startup_check } end def irc_privmsg_event(stem, sender, arguments) # :nodoc: database do if arguments[:channel] then command_parse stem, sender, arguments did_receive_channel_message stem, sender, arguments[:channel], arguments[:message] else command_parse stem, sender, arguments if options[:respond_to_private_messages] did_receive_private_message stem, sender, arguments[:message] end end end def irc_join_event(stem, sender, arguments) # :nodoc: database { someone_did_join_channel stem, sender, arguments[:channel] } end def irc_part_event(stem, sender, arguments) # :nodoc: database { someone_did_leave_channel stem, sender, arguments[:channel] } end def irc_mode_event(stem, sender, arguments) # :nodoc: database do if arguments[:recipient] then gained_usermodes(stem, arguments[:mode]) { |prop| someone_did_gain_usermode stem, arguments[:recipient], prop, arguments[:parameter], sender } lost_usermodes(stem, arguments[:mode]) { |prop| someone_did_lose_usermode stem, arguments[:recipient], prop, arguments[:parameter], sender } elsif arguments[:parameter] and stem.server_type.privilege_mode?(arguments[:mode]) then gained_privileges(stem, arguments[:mode]) { |prop| someone_did_gain_privilege stem, arguments[:channel], arguments[:parameter], prop, sender } lost_privileges(stem, arguments[:mode]) { |prop| someone_did_lose_privilege stem, arguments[:channel], arguments[:parameter], prop, sender } else gained_properties(stem, arguments[:mode]) { |prop| channel_did_gain_property stem, arguments[:channel], prop, arguments[:parameter], sender } lost_properties(stem, arguments[:mode]) { |prop| channel_did_lose_property stem, arguments[:channel], prop, arguments[:parameter], sender } end end end def irc_topic_event(stem, sender, arguments) # :nodoc: database { someone_did_change_topic stem, sender, arguments[:channel], arguments[:topic] } end def irc_invite_event(stem, sender, arguments) # :nodoc: database { someone_did_invite stem, sender, arguments[:recipient], arguments[:channel] } end def irc_kick_event(stem, sender, arguments) # :nodoc: database { someone_did_kick stem, sender, arguments[:channel], arguments[:recipient], arguments[:message] } end def irc_notice_event(stem, sender, arguments) # :nodoc: database do if arguments[:recipient] then did_receive_notice stem, sender, arguments[:recipient], arguments[:message] else did_receive_notice stem, sender, arguments[:channel], arguments[:message] end end end def irc_nick_event(stem, sender, arguments) # :nodoc: database { nick_did_change stem, sender, arguments[:nick] } end def irc_quit_event(stem, sender, arguments) # :nodoc: database { someone_did_quit stem, sender, arguments[:message] } end ########################### OTHER PUBLIC METHODS ########################### # Invoked just before the leaf starts up. Override this method to do any # pre-startup tasks you need. The leaf is fully initialized and all methods # and helper objects are available. def will_start_up end # Performs the block in the context of a database, referenced by symbol. For # instance, if you had defined in database.yml a connection named # "scorekeeper", you could access that connection like so: # # database(:scorekeeper) do # [...] # end # # If your database is named after your leaf (as in the example above for a # leaf named "Scorekeeper"), it will automatically be set as the database # context for the scope of all hook, filter and command methods. However, if # your database connection is named differently, or if you are working in a # method not invoked by the Leaf class, you will need to set the connection # using this method. # # If you omit the +dbname+ parameter, it will try to guess the name of your # database connection using the leaf's name and the leaf's class name. # # If the database connection cannot be found, the block is executed with no # database scope. def database(dbname=nil, &block) dbname ||= database_name if dbname then repository dbname, &block else yield end end # Trues to guess the name of the database connection this leaf is using. # Looks for database connections named after either this leaf's identifier # or this leaf's class name. Returns nil if no suitable connection is found. def database_name # :nodoc: return nil unless Module.constants.include? 'DataMapper' or Module.constants.include? :DataMapper raise "No such database connection #{options[:database]}" if options[:database] and DataMapper::Repository.adapters[options[:database]].nil? # Custom database connection specified return options[:database].to_sym if options[:database] # Leaf config name return leaf_name.to_sym if DataMapper::Repository.adapters[leaf_name.to_sym] # Leaf config name, underscored return leaf_name.methodize.to_sym if DataMapper::Repository.adapters[leaf_name.methodize.to_sym] # Leaf class name return self.class.to_s.to_sym if DataMapper::Repository.adapters[self.class.to_s.to_sym] # Leaf class name, underscored return self.class.to_s.methodize.to_sym if DataMapper::Repository.adapters[self.class.to_s.methodize.to_sym] # I give up return nil end def inspect # :nodoc: "#<#{self.class.to_s} #{leaf_name}>" end protected # Duplicates a command. This method aliases the command method and also # ensures the correct view file is rendered if appropriate. # # alias_command :google, :g def self.alias_command(old, new) raise NoMethodError, "Unknown command #{old}" unless instance_methods.include?("#{old}_command") alias_method "#{new}_command", "#{old}_command" @@view_alias[new] = old end # Adds a filter to the end of the list of filters to be run before a command # is executed. You can use these filters to perform tasks that prepare the # leaf to respond to a command, or to determine whether or not a command # should be run (e.g., authentication). Pass the name of your filter as a # symbol, and an optional has of options: # # +only+:: Only run the filter for these commands # +except+:: Do not run the filter for these commands # # Each option can refer to a single command or an Array of commands. # Commands should be symbols such as <tt>:quit</tt> for the !quit command. # # Your method will be called with these parameters: # # 1. the Stem instance that received the command, # 2. the name of the channel to which the command was sent (or nil if it was # a private message), # 3. the sender hash, # 4. the name of the command that was typed, as a symbol, # 5. any additional parameters after the command (same as the +msg+ # parameter in the <tt>*_command</tt> methods), # 6. the custom options that were given to before_filter. # # If your filter returns either nil or false, the filter chain will be # halted and the command will not be run. For example, if you create the # filter: # # before_filter :read_files, :only => [ :quit, :reload ], :remote_files => true # # then any time the bot receives a "!quit" or "!reload" command, it will # first evaluate: # # read_files_filter <stem>, <channel>, <sender hash>, <command>, <message>, { :remote_files => true } # # and if the result is not false or nil, the command will be executed. def self.before_filter(filter, options={}) if options[:only] and not options[:only].kind_of? Array then options[:only] = [ options[:only] ] end if options[:except] and not options[:except].kind_of? Array then options[:except] = [ options[:except] ] end write_inheritable_array 'before_filters', [ [ filter.to_sym, options ] ] end # Adds a filter to the end of the list of filters to be run after a command # is executed. You can use these filters to perform tasks that must be done # after a command is run, such as cleaning up temporary files. Pass the name # of your filter as a symbol, and an optional has of options. See the # before_filter docs for more. # # Your method will be called with five parameters -- see the before_filter # method for more information. Unlike before_filter filters, however, any # return value is ignored. For example, if you create the filter: # # after_filter :clean_tmp, :only => :sendfile, :remove_symlinks => true # # then any time the bot receives a "!sendfile" command, after running the # command it will evaluate: # # clean_tmp_filter <stem>, <channel>, <sender hash>, :sendfile, <message>, { :remove_symlinks => true } def self.after_filter(filter, options={}) if options[:only] and not options[:only].kind_of? Array then options[:only] = [ options[:only] ] end if options[:except] and not options[:except].kind_of? Array then options[:except] = [ options[:except] ] end write_inheritable_array 'after_filters', [ [ filter.to_sym, options ] ] end # Invoked after the leaf is started up and is ready to accept commands. # Override this method to do any post-startup tasks you need, such as # displaying a greeting message. def did_start_up end # Invoked just before the leaf exists. Override this method to perform any # pre-shutdown tasks you need. def will_quit end # Invoked when the leaf receives a private (whispered) message. +sender+ is # a sender hash. def did_receive_private_message(stem, sender, msg) end # Invoked when a message is sent to a channel the leaf is a member of (even # if that message was a valid command). +sender+ is a sender hash. def did_receive_channel_message(stem, sender, channel, msg) end # Invoked when someone joins a channel the leaf is a member of. +person+ is # a sender hash. def someone_did_join_channel(stem, person, channel) end # Invoked when someone leaves a channel the leaf is a member of. +person+ is # a sender hash. def someone_did_leave_channel(stem, person, channel) end # Invoked when someone gains a channel privilege. +privilege+ can be any # value returned by the stem's Daemon. If the privilege is not in the hash, # it will be a string (not a symbol) equal to the letter value for that # privilege (e.g., 'v' for voice). +bestower+ is a sender hash. def someone_did_gain_privilege(stem, channel, nick, privilege, bestower) end # Invoked when someone loses a channel privilege. def someone_did_lose_privilege(stem, channel, nick, privilege, bestower) end # Invoked when a channel gains a property. +property+ can be any value # returned by the stem's Daemon. If the peroperty is not in the hash, it # will be a string (not a symbol) equal to the letter value for that # property (e.g., 'k' for password). If the property takes an argument (such # as user limit or password), it will be passed via +argument+ (which is # otherwise nil). +bestower+ is a sender hash. def channel_did_gain_property(stem, channel, property, argument, bestower) end # Invoked when a channel loses a property. def channel_did_lose_property(stem, channel, property, argument, bestower) end # Invoked when someone gains a user mode. +mode+ can be an value returned by # the stem's Daemon. If the mode is not in the hash, it will be a string # (not a symbol) equal to the letter value for that mode (e.g., 'i' for # invisible). +bestower+ is a sender hash. def someone_did_gain_usermode(stem, nick, mode, argument, bestower) end # Invoked when someone loses a user mode. def someone_did_lose_usermode(stem, nick, mode, argument, bestower) end # Invoked when someone changes a channel's topic. +topic+ is the new topic. # +person+ is a sender hash. def someone_did_change_topic(stem, person, channel, topic) end # Invoked when someone invites another person to a channel. For some IRC # servers, this will only be invoked if the leaf itself is invited into a # channel. +inviter+ is a sender hash; +invitee+ is a nick. def someone_did_invite(stem, inviter, invitee, channel) end # Invoked when someone is kicked from a channel. Note that this is called # when your leaf is kicked as well, so it may well be the case that # +channel+ is a channel you are no longer in! +kicker+ is a sender hash; # +victim+ is a nick. def someone_did_kick(stem, kicker, channel, victim, msg) end # Invoked when a notice is received. Notices are like channel or pivate # messages, except that leaves are expected _not_ to respond to them. # +sender+ is a sender hash; +recipient+ is either a channel or a nick. def did_receive_notice(stem, sender, recipient, msg) end # Invoked when a user changes his nick. +person+ is a sender hash containing # the person's old nick, and +nick+ is their new nick. def nick_did_change(stem, person, nick) end # Invoked when someone quits IRC. +person+ is a sender hash. def someone_did_quit(stem, person, msg) end UNADVERTISED_COMMANDS = [ 'about', 'commands' ] # :nodoc: # Typing this command displays a list of all commands for each leaf running # off this stem. def commands_command(stem, sender, reply_to, msg) commands = self.class.instance_methods.select { |m| m =~ /^\w+_command$/ } commands.map! { |m| m.match(/^(\w+)_command$/)[1] } commands.reject! { |m| UNADVERTISED_COMMANDS.include? m } return if commands.empty? commands.map! { |c| "#{options[:command_prefix]}#{c}" } "Commands for #{leaf_name}: #{commands.sort.join(', ')}" end # Sets a custom view name to render. The name doesn't have to correspond to # an actual command, just an existing view file. Example: # # def my_command(stem, sender, reply_to, msg) # render :help and return if msg.empty? # user doesn't know how to use the command # [...] # end # # Only one view is rendered per command. If this method is called multiple # times, the last value set is used. This method has no effect outside of # a <tt>*_command</tt> method. # # By default, the view named after the command will be rendered. If no such # view exists, the value returned by the method will be used as the # response. def render(view) # Since only one command is executed per thread, we can store the view to # render as a thread-local variable. raise "The render method should be called at most once per command" if Thread.current[:render_view] Thread.current[:render_view] = view.to_s return nil end # Gets or sets a variable for use in the view. Use this method in # <tt>*_command</tt> methods to pass data to the view ERb file, and in the # ERb file to retrieve these values. For example, in your controller.rb # file: # # def my_command(stem, sender, reply_to, msg) # var :num_lights => 4 # end # # And in your my.txt.erb file: # # THERE ARE <%= var :num_lights %> LIGHTS! def var(vars) return Thread.current[:vars][vars] if vars.kind_of? Symbol return vars.each { |var, val| Thread.current[:vars][var] = val } if vars.kind_of? Hash raise ArgumentError, "var must take a symbol or a hash" end private def startup_check return if @started_up @started_up = true did_start_up end def command_parse(stem, sender, arguments) if arguments[:channel] or options[:respond_to_private_messages] then reply_to = arguments[:channel] ? arguments[:channel] : sender[:nick] matches = arguments[:message].match(/^#{Regexp.escape options[:command_prefix]}(\w+)\s*(.*)$/) if matches then name = matches[1].to_sym msg = matches[2] origin = sender.merge(:stem => stem) command_exec name, stem, arguments[:channel], sender, msg, reply_to end end end def command_exec(name, stem, channel, sender, msg, reply_to) cmd_sym = "#{name}_command".to_sym return unless respond_to? cmd_sym msg = nil if msg.empty? return unless authenticated?(name, stem, channel, sender) return unless run_before_filters(name, stem, channel, sender, name, msg) Thread.current[:vars] = Hash.new return_val = send(cmd_sym, stem, sender, reply_to, msg) view = Thread.current[:render_view] view ||= @@view_alias[name] if return_val.kind_of? String then stem.message return_val, reply_to elsif options[:views][view.to_s] then stem.message parse_view(view.to_s), reply_to #else # raise "You must either specify a view to render or return a string to send." end Thread.current[:vars] = nil Thread.current[:render_view] = nil # Clear it out in case the command is synchronized run_after_filters name, stem, channel, sender, name, msg end def parse_view(name) return nil unless options[:views][name] ERB.new(options[:views][name]).result(binding) end def leaf_name Foliater.instance.leaves.index self end def run_before_filters(cmd, stem, channel, sender, command, msg) command = cmd.to_sym self.class.before_filters.each do |filter, options| local_opts = options.dup next if local_opts[:only] and not local_opts.delete(:only).include? command next if local_opts[:except] and local_opts.delete(:except).include? command return false unless method("#{filter}_filter")[stem, channel, sender, command, msg, local_opts] end return true end def run_after_filters(cmd, stem, channel, sender, command, msg) command = cmd.to_sym self.class.after_filters.each do |filter, options| local_opts = options.dup next if local_opts[:only] and not local_opts.delete(:only).include? command next if local_opts[:except] and local_opts.delete(:except).include? command method("#{filter}_filter")[stem, channel, sender, command, msg, local_opts] end end def authenticated?(cmd, stem, channel, sender) return true if @authenticator.nil? # Any method annotated as protected is authenticated unconditionally if not self.class.ann("#{cmd}_command".to_sym, :protected) then return true end if @authenticator.authenticate(stem, channel, sender, self) then return true else stem.message @authenticator.unauthorized, channel unless options[:authentication]['silent'] return false end end def gained_privileges(stem, privstr) return unless privstr[0,1] == '+' privstr.except_first.each_char { |c| yield stem.server_type.privilege[c] } end def lost_privileges(stem, privstr) return unless privstr[0,1] == '-' privstr.except_first.each_char { |c| yield stem.server_type.privilege[c] } end def gained_properties(stem, propstr) return unless propstr[0,1] == '+' propstr.except_first.each_char { |c| yield stem.server_type.channel_mode[c] } end def lost_properties(stem, propstr) return unless propstr[0,1] == '-' propstr.except_first.each_char { |c| yield stem.server_type.channel_mode[c] } end def gained_usermodes(stem, modestr) return unless modestr[0,1] == '+' modestr.except_first.each_char { |c| yield stem.server_type.usermode[c] } end def lost_usermodes(stem, modestr) return unless modestr[0,1] == '-' modestr.except_first.each_char { |c| yield stem.server_type.usermode[c] } end def self.before_filters read_inheritable_attribute('before_filters') or [] end def self.after_filters read_inheritable_attribute('after_filters') or [] end end end