ZyklonB: Add a "simple" pomodoro plugin in Ruby
This commit is contained in:
parent
c492473ade
commit
4a6f01763b
|
@ -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
|
Loading…
Reference in New Issue