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 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
 |