#!/usr/bin/env ruby # coding: utf-8 # # ZyklonB pomodoro plugin # # Copyright 2015 Přemysl Janouch # See the file LICENSE for licensing information. # # --- Simple event loop -------------------------------------------------------- # This is more or less a straight-forward port of my C event loop. It's a bit # unfortunate that I really have to implement all this in order to get some # basic asynchronicity but at least I get to exercise my Ruby. class TimerEvent attr_accessor :index, :when, :callback def initialize (callback) raise ArgumentError unless callback.is_a? Proc @index = nil @when = nil @callback = callback end def active? @index != nil end def until return @when - Time.new end end class IOEvent READ = 1 << 0 WRITE = 1 << 1 attr_accessor :read_index, :write_index, :io, :callback def initialize (io, callback) raise ArgumentError unless callback.is_a? Proc @read_index = nil @write_index = nil @io = io @callback = callback end end class EventLoop def initialize @running = false @timers = [] @readers = [] @writers = [] @io_to_event = {} end def set_timer (timer, timeout) raise ArgumentError unless timer.is_a? TimerEvent timer.when = Time.now + timeout if timer.index heapify_down timer.index heapify_up timer.index else timer.index = @timers.size @timers.push timer heapify_up timer.index end end def reset_timer (timer) raise ArgumentError unless timer.is_a? TimerEvent remove_timer_at timer.index if timer.index end def set_io (io_event, events) raise ArgumentError unless io_event.is_a? IOEvent raise ArgumentError unless events.is_a? Numeric reset_io io_event @io_to_event[io_event.io] = io_event if events & IOEvent::READ io_event.read_index = @readers.size @readers.push io_event.io end if events & IOEvent::WRITE io_event.read_index = @writers.size @writers.push io_event.io end end def reset_io (io_event) raise ArgumentError unless io_event.is_a? IOEvent @readers.delete_at io_event.read_index if io_event.read_index @writers.delete_at io_event.write_index if io_event.write_index io_event.read_index = nil io_event.write_index = nil @io_to_event.delete io_event.io end def run @running = true while @running do one_iteration end end def quit @running = false end private def one_iteration rs, ws, = IO.select @readers, @writers, [], nearest_timeout dispatch_timers (Array(rs) | Array(ws)).each do |io| @io_to_event[io].callback.call io end end def dispatch_timers now = Time.new while not @timers.empty? and @timers[0].when <= now do @timers[0].callback.call remove_timer_at 0 end end def nearest_timeout return nil if @timers.empty? timeout = @timers[0].until if timeout < 0 then 0 else timeout end end def remove_timer_at (index) @timers[index].index = nil moved = @timers.pop return if index == @timers.size @timers[index] = moved @timers[index].index = index heapify_down index end def swap_timers (a, b) @timers[a], @timers[b] = @timers[b], @timers[a] @timers[a].index = a @timers[b].index = b end def heapify_up (index) while index != 0 do parent = (index - 1) / 2 break if @timers[parent].when <= @timers[index].when swap_timers index, parent index = parent end end def heapify_down (index) loop do parent = index left = 2 * index + 1 right = 2 * index + 2 lowest = parent lowest = left if left < @timers.size and @timers[left] .when < @timers[lowest].when lowest = right if right < @timers.size and @timers[right].when < @timers[lowest].when break if parent == lowest swap_timers lowest, parent index = lowest end end end # --- IRC protocol ------------------------------------------------------------- $stdin.set_encoding 'ASCII-8BIT' $stdout.set_encoding 'ASCII-8BIT' $stdin.sync = true $stdout.sync = true $/ = "\r\n" $\ = "\r\n" RE_MSG = /(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(?: +(.*))?$/ RE_ARGS = /:?((?<=:).*|[^ ]+) */ def parse (line) m = line.match RE_MSG return nil if not m nick, user, host, command, args = *m.captures args = if args then args.scan(RE_ARGS).flatten else [] end [nick, user, host, command, args] end def bot_print (what) print "ZYKLONB print :#{what}" end # --- Initialization ----------------------------------------------------------- # We can only read in configuration from here so far # To read it from anywhere else, it has to be done asynchronously $config = {} [:prefix].each do |name| print "ZYKLONB get_config :#{name}" _, _, _, _, args = *parse($stdin.gets.chomp) $config[name] = args[0] end print "ZYKLONB register" # --- Plugin logic ------------------------------------------------------------- # FIXME: this needs a major refactor as it doesn't make much sense at all class MessageMeta < Struct.new(:nick, :user, :host, :channel, :ctx, :quote) def respond (message) print "PRIVMSG #{ctx} :#{quote}#{message}" end end class Context attr_accessor :nick, :ctx def initialize (meta) @nick = meta.nick @ctx = meta.ctx end def == (other) self.class == other.class \ and other.nick == @nick \ and other.ctx == @ctx end alias eql? == def hash @nick.hash ^ @ctx.hash end end class PomodoroTimer def initialize (context) @ctx = context.ctx @nicks = [context.nick] @timer_work = TimerEvent.new(lambda { on_work }) @timer_rest = TimerEvent.new(lambda { on_rest }) on_work end def inform (message) # FIXME: it tells the nick even in PM's quote = "#{@nicks.join(" ")}: " print "PRIVMSG #{@ctx} :#{quote}#{message}" end def on_work inform "work now!" $loop.set_timer @timer_rest, 25 * 60 end def on_rest inform "rest now!" $loop.set_timer @timer_work, 5 * 60 end def join (meta) return if @nicks.include? meta.nick meta.respond "you have joined their pomodoro" @nicks |= [meta.nick] end def part (meta, requested) return if not @nicks.include? meta.nick if requested meta.respond "you have stopped your pomodoro" end @nicks -= [meta.nick] if @nicks.empty? $loop.reset_timer @timer_work $loop.reset_timer @timer_rest end end def status (meta) return if not @nicks.include? meta.nick if @timer_rest.active? till = @timer_rest.until meta.respond "working, #{(till / 60).to_i} minutes, " + "#{(till % 60).to_i} seconds until rest" end if @timer_work.active? till = @timer_work.until meta.respond "resting, #{(till / 60).to_i} minutes, " + "#{(till % 60).to_i} seconds until work" end end end class Pomodoro KEYWORD = "pomodoro" def initialize @timers = {} end def on_help (meta, args) meta.respond "usage: #{KEYWORD} { start | stop | join | status }" end def on_start (meta, args) if args.size != 0 meta.respond "usage: #{KEYWORD} start" return end context = Context.new meta if @timers[context] meta.respond "you already have a timer running here" else @timers[context] = PomodoroTimer.new meta end end def on_join (meta, args) if args.size != 1 meta.respond "usage: #{KEYWORD} join " return end context = Context.new meta if @timers[context] meta.respond "you already have a timer running here" return end joined_context = Context.new meta joined_context.nick = args.shift timer = @timers[joined_context] if not timer meta.respond "that person doesn't have a timer here" else timer.join meta @timers[context] = timer end end def on_stop (meta, args) if args.size != 0 meta.respond "usage: #{KEYWORD} stop" return end context = Context.new meta timer = @timers[context] if not timer meta.respond "you don't have a timer running here" else timer.part meta, true @timers.delete context end end def on_status (meta, args) if args.size != 0 meta.respond "usage: #{KEYWORD} status" return end timer = @timers[Context.new meta] if not timer meta.respond "you don't have a timer running here" else timer.status meta end end def process_command (meta, msg) args = msg.split return if args.shift != KEYWORD method = "on_#{args.shift}" send method, meta, args if respond_to? method end def on_server_nick (meta, command, args) # TODO: either handle this properly... happened = false @timers.keys.each do |key| next if key.nick != meta.nick @timers[key].part meta, false @timers.delete key happened = true end if happened # TODO: ...or at least inform the user via his new nick end end def on_server_part (meta, command, args) # TODO: instead of cancelling the user's pomodoros, either redirect # them to PM's and later upon rejoining undo the redirection... context = Context.new(meta) context.ctx = meta.channel if @timers.include? context # TODO: ...or at least inform the user about the cancellation @timers[context].part meta, false @timers.delete context end end def on_server_quit (meta, command, args) @timers.keys.each do |key| next if key.nick != meta.nick @timers[key].part meta, false @timers.delete key end end def process (meta, command, args) method = "on_server_#{command.downcase}" send method, meta, command, args if respond_to? method end end # --- IRC message processing --------------------------------------------------- $handlers = [Pomodoro.new] def process_line (line) msg = parse line return if not msg nick, user, host, command, args = *msg context = nick quote = "" channel = nil if args.size >= 1 and args[0].start_with? ?#, ?+, ?&, ?! case command when "PRIVMSG", "NOTICE", "JOIN" context = args[0] quote = "#{nick}: " channel = args[0] when "PART" channel = args[0] end end # Handle any IRC message meta = MessageMeta.new(nick, user, host, channel, context, quote).freeze $handlers.each do |handler| handler.process meta, command, args end # Handle pre-processed bot commands if command == 'PRIVMSG' and args.size >= 2 msg = args[1] return unless msg.start_with? $config[:prefix] $handlers.each do |handler| handler.process_command meta, msg[$config[:prefix].size..-1] end end end buffer = "" stdin_io = IOEvent.new($stdin, lambda do |io| begin buffer << io.read_nonblock(4096) lines = buffer.split $/, -1 buffer = lines.pop lines.each { |line| process_line line } rescue EOFError $loop.quit rescue IO::WaitReadable # Ignore end end) $loop = EventLoop.new $loop.set_io stdin_io, IOEvent::READ $loop.run