503 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Ruby
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			503 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Ruby
		
	
	
		
			Executable File
		
	
	
	
	
#!/usr/bin/env ruby
 | 
						|
# coding: utf-8
 | 
						|
#
 | 
						|
# xB pomodoro plugin
 | 
						|
#
 | 
						|
# Copyright 2015 Přemysl Eric 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 "XB 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 "XB get_config :#{name}"
 | 
						|
	_, _, _, _, args = *parse($stdin.gets.chomp)
 | 
						|
	$config[name] = args[0]
 | 
						|
end
 | 
						|
 | 
						|
print "XB 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
 |