# File: EventQueue.pm # # Purpose: Provides functionality to manage event subroutines which are invoked # at a future time, optionally recurring. # # Note: PBot::EventQueue has no relation to PBot::EventDispatcher. # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. package PBot::EventQueue; use parent 'PBot::Class'; use PBot::Imports; use Time::HiRes qw/time/; use Time::Duration; sub initialize { my ($self, %conf) = @_; # array of pending events $self->{event_queue} = []; # register `eventqueue` bot command $self->{pbot}->{commands}->register(sub { $self->cmd_eventqueue(@_) }, 'eventqueue', 1); # add `can-eventqueue` capability to admin group $self->{pbot}->{capabilities}->add('admin', 'can-eventqueue', 1); } # eventqueue bot command sub cmd_eventqueue { my ($self, $context) = @_; my $usage = "Usage: eventqueue list [filter regex] | add [-repeat] | remove "; my $command = $self->{pbot}->{interpreter}->shift_arg($context->{arglist}); if (not defined $command) { return $usage; } if ($command eq 'list') { return "No events queued." if not @{$self->{event_queue}}; my $result = eval { my $text = "Queued events:\n"; my ($regex) = $self->{pbot}->{interpreter}->shift_arg($context->{arglist}); my $i = 0; my $events = 0; foreach my $event (@{$self->{event_queue}}) { $i++; if ($regex) { next unless $event->{id} =~ /$regex/i; } $events++; my $duration = $event->{timeout} - time; if ($duration < 0) { # current time has passed an event's time but the # event hasn't left the queue yet. we'll show these # as, e.g., "pending 5s ago" $duration = 'pending ' . concise ago -$duration; } else { $duration = 'in ' . concise duration $duration; } $text .= " $i) $duration: $event->{id}"; $text .= ' [R]' if $event->{repeating}; $text .= ";\n"; } return "No events found." if $events == 0; return $text . "$events events.\n"; }; if (my $error = $@) { # strip source information to prettify error for non-developer consumption $error =~ s/ at PBot.*//; return "Bad regex: $error"; } return $result; } if ($command eq 'add') { my ($duration, $command) = $self->{pbot}->{interpreter}->split_args($context->{arglist}, 2); if (not defined $duration or not defined $command) { return "Usage: eventqueue add [-repeat]"; } # convert text like "5 minutes" or "1 week" or "next tuesday" to seconds my ($seconds, $error) = $self->{pbot}->{parsedate}->parsedate($duration); return $error if defined $error; # check for `-repeating` at front or end of command my $repeating = $command =~ s/^-repeat\s+|\s+-repeat$//g; my $cmd = { nick => $context->{nick}, user => $context->{user}, host => $context->{host}, hostmask => $context->{hostmask}, command => $command, }; $self->{pbot}->{interpreter}->add_to_command_queue($context->{from}, $cmd, $seconds, $repeating); return "Command added to event queue."; } if ($command eq 'remove') { my ($regex) = $self->{pbot}->{interpreter}->split_args($context->{arglist}, 1); return "Usage: eventqueue remove " if not defined $regex; $regex =~ s/(?dequeue_event($regex); } return "Unknown command '$command'. $usage"; } # returns seconds until upcoming event. sub duration_until_next_event { my ($self) = @_; return 0 if not @{$self->{event_queue}}; return $self->{event_queue}->[0]->{timeout} - time; } # invokes any current events and then returns seconds until upcoming event. sub do_events { my ($self) = @_; # early-return if no events available return 0 if not @{$self->{event_queue}}; my $debug = $self->{pbot}->{registry}->get_value('eventqueue', 'debug') // 0; # repeating events to re-enqueue my @enqueue; for (my $i = 0; $i < @{$self->{event_queue}}; $i++) { # we call time for a fresh time, instead of using a stale $now that # could be well in the past depending on a previous event's duration if (time >= $self->{event_queue}->[$i]->{timeout}) { my $event = $self->{event_queue}->[$i]; $self->{pbot}->{logger}->log("Processing event $i: $event->{id}\n") if $debug > 1; # call event's subref, passing event as argument $event->{subref}->($event); # remove event from queue splice @{$self->{event_queue}}, $i--, 1; # add event to re-enqueue queue if repeating push @enqueue, $event if $event->{repeating}; } else { # no more events ready at this time if ($debug > 2) { $self->{pbot}->{logger}->log("Event not ready yet: $self->{event_queue}->[$i]->{id} (timeout=$self->{event_queue}->[$i]->{timeout})\n"); } last; } } # re-enqueue repeating events foreach my $event (@enqueue) { $self->enqueue_event($event->{subref}, $event->{interval}, $event->{id}, 1); } return $self->duration_until_next_event; } # check if an event is in the event queue. sub exists { my ($self, $id) = @_; return scalar grep { $_->{id} eq $id } @{$self->{event_queue}}; } # quickly and efficiently find the best position in the event # queue array for a given time value sub find_enqueue_position { my ($self, $time) = @_; # no events in queue yet, early-return first position return 0 if not @{$self->{event_queue}}; # early-return first position if event's time is less # than first position's if ($time < $self->{event_queue}->[0]->{timeout}) { return 0; } # early-return last position if event's time is greater if ($time > $self->{event_queue}->[@{$self->{event_queue}} - 1]->{timeout}) { return scalar @{$self->{event_queue}}; } # binary search to find enqueue position my $lo = 0; my $hi = scalar @{$self->{event_queue}} - 1; while ($lo <= $hi) { my $mid = int (($hi + $lo) / 2); if ($time < $self->{event_queue}->[$mid]->{timeout}) { $hi = $mid - 1; } elsif ($time > $self->{event_queue}->[$mid]->{timeout}) { $lo = $mid + 1; } else { while ($mid < @{$self->{event_queue}} and $self->{event_queue}->[$mid]->{timeout} == $time) { # found a slot with the same time. we "slide" down the array # to append this event to the end of this region of same-times. $mid++; } return $mid; } } return $lo; } # adds an event to the event queue, optionally repeating sub enqueue_event { my ($self, $subref, $interval, $id, $repeating) = @_; # default values $id //= "unnamed (${interval}s $subref)"; $repeating //= 0; $interval //= 0; # create event structure my $event = { id => $id, subref => $subref, interval => $interval, timeout => time + $interval, repeating => $repeating, }; # find position to add event my $i = $self->find_enqueue_position($event->{timeout}); # add the event to the event queue array splice @{$self->{event_queue}}, $i, 0, $event; # debugging noise my $debug = $self->{pbot}->{registry}->get_value('eventqueue', 'debug') // 0; if ($debug > 1) { $self->{pbot}->{logger}->log("Enqueued new event $id at position $i: timeout=$event->{timeout} interval=$interval repeating=$repeating\n"); } } # convenient alias to add an event with repeating defaulted to enabled. sub enqueue { my ($self, $subref, $interval, $id, $repeating) = @_; $self->enqueue_event($subref, $interval, $id, $repeating // 1); } # removes an event from the event queue, optionally invoking it. # `id` can contain `.*` and `.*?` for wildcard-matching/globbing. sub dequeue_event { my ($self, $id, $execute) = @_; my $result = eval { # escape special characters $id = quotemeta $id; # unescape .* and .*? $id =~ s/\\\.\\\*\\\?/.*?/g; $id =~ s/\\\.\\\*/.*/g; # compile regex my $regex = qr/^$id$/i; # count total events before removal my $count = @{$self->{event_queue}}; # collect events to be removed my @removed = grep { $_->{id} =~ /$regex/i; } @{$self->{event_queue}}; # remove events from event queue @{$self->{event_queue}} = grep { $_->{id} !~ /$regex/i; } @{$self->{event_queue}}; # set count to total events removed $count -= @{$self->{event_queue}}; # invoke removed events, if requested if ($execute) { foreach my $event (@removed) { $event->{subref}->($event); } } # nothing removed return "No matching events." if not $count; # list all removed events return "Removed $count event" . ($count == 1 ? '' : 's') . ': ' . join(', ', map { $_->{id} } @removed); }; if ($@) { my $error = $@; $self->{pbot}->{logger}->log("Error in dequeue_event: $error\n"); $error =~ s/ at PBot.*//; return "$error"; } return $result; } # alias to dequeue_event, for consistency. sub dequeue { my ($self, $id) = @_; $self->dequeue_event($id); } # invoke and remove all events matching `id`, which can # contain `.*` and `.*?` for wildcard-matching/globbing. sub execute_and_dequeue_event { my ($self, $id) = @_; return $self->dequeue_event($id, 1); } # replace code subrefs for matching events. if no events # were found, then add the event to the event queue. sub replace_subref_or_enqueue_event { my ($self, $subref, $interval, $id, $repeating) = @_; # find events matching id my @events = grep { $_->{id} eq $id } @{$self->{event_queue}}; # no events found, enqueue new event if (not @events) { $self->enqueue_event($subref, $interval, $id, $repeating); return; } # otherwise update existing events foreach my $event (@events) { $event->{subref} = $subref; } } # remove existing events of this id then enqueue new event. sub replace_or_enqueue_event { my ($self, $subref, $interval, $id, $repeating) = @_; # remove event if it exists $self->dequeue_event($id) if $self->exists($id); # enqueue new event $self->enqueue_event($subref, $interval, $id, $repeating); } # add event unless it already had been added. sub enqueue_event_unless_exists { my ($self, $subref, $interval, $id, $repeating) = @_; # event already exists, bail out return if $self->exists($id); # enqueue new event $self->enqueue_event($subref, $interval, $id, $repeating); } # update the `repeating` flag for all events matching `id`. sub update_repeating { my ($self, $id, $repeating) = @_; for (my $i = 0; $i < @{$self->{event_queue}}; $i++) { if ($self->{event_queue}->[$i]->{id} eq $id) { $self->{event_queue}->[$i]->{repeating} = $repeating; } } } # update the `interval` value for all events matching `id`. sub update_interval { my ($self, $id, $interval, $dont_enqueue) = @_; for (my $i = 0; $i < @{$self->{event_queue}}; $i++) { if ($self->{event_queue}->[$i]->{id} eq $id) { if ($dont_enqueue) { # update interval in-place without moving event to new place in queue # (allows event to fire at expected time, then updates to new timeout afterwards) $self->{event_queue}->[$i]->{interval} = $interval; } else { # remove and add event in new position in queue my $event = splice(@{$self->{event_queue}}, $i, 1); $self->enqueue_event($event->{subref}, $interval, $id, $event->{repeating}); } } } } 1;