xK/plugins/pomodoro

503 lines
10 KiB
Ruby
Executable File

#!/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 <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