503 lines
10 KiB
Ruby
Executable File
503 lines
10 KiB
Ruby
Executable File
#!/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
|