aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xplugins/pomodoro502
1 files changed, 502 insertions, 0 deletions
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 <nick> | 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 <nick>"
+ 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