diff --git a/plugins/pomodoro b/plugins/pomodoro new file mode 100755 index 0000000..b2ea820 --- /dev/null +++ b/plugins/pomodoro @@ -0,0 +1,502 @@ +#!/usr/bin/env ruby +# coding: utf-8 +# +# ZyklonB pomodoro plugin +# +# Copyright 2015 Přemysl Janouch. All rights reserved. +# 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 @timers.size - 1 + 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].when - Time.new + 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