class Reminder
Constants
- DATETIME_PATTERN
- DEFAULT_TZ
- DURATION_PATTERN
- IRC_CTRL
- IRC_RESET
- KEY
- MAX_SECONDS
- PLUGIN
- STYLE_TITLE
- TIMEZONE_PATTERN
- TIME_PATTERN
- TZ_KEY
Public Class Methods
Source
# File plugins/reminder.rb, line 375 def self.active_pending_for(nick:, network:, channel:, store: RabbotDb, now: Time.now) pending_entries(network: network, channel: channel, store: store).select do |entry| next false unless entry["nick"].to_s.casecmp?(nick.to_s) at = store.parse_time(entry["at"]) at && at > now end.sort_by { |entry| store.parse_time(entry["at"]) } end
Source
# File plugins/reminder.rb, line 430 def self.add_pending(network:, channel:, nick:, message:, at:, seconds:, id: SecureRandom.hex(4), store: RabbotDb) entry = { "id" => id.to_s, "nick" => nick.to_s, "message" => message.to_s, "at" => RabbotDb.iso_time(at), "seconds" => seconds.to_i } entries = pending_entries(network: network, channel: channel, store: store) entries << entry save_pending(network: network, channel: channel, entries: entries, store: store) entry end
Source
# File plugins/reminder.rb, line 75 def self.channel_timezone(network:, channel:, store: RabbotDb) store.get_plugin_value( plugin: PLUGIN, key: TZ_KEY, network: network, channel: channel ).to_s.strip end
Source
# File plugins/reminder.rb, line 450 def self.due_pending(network:, channel:, now: Time.now, store: RabbotDb) pending_entries(network: network, channel: channel, store: store).select do |entry| at = store.parse_time(entry["at"]) at && at <= now end end
Source
# File plugins/reminder.rb, line 346 def self.format_ack(nick:, message:, at:, seconds: nil, now: Time.now, utc_offset: now.utc_offset, timezone_name: nil) due_at = at.is_a?(Time) ? at : RabbotDb.parse_time(at) seconds = (due_at - now).to_i if due_at && seconds.nil? due_label = due_at ? due_at.getlocal(utc_offset).strftime("%H:%M") : "?" title = "#{nick} · in #{format_duration(seconds)} · due #{due_label}" footer_parts = [] tz_label = timezone_short_label(timezone_name) footer_parts << tz_label unless tz_label.empty? footer_parts << "reminder set" [ IrcFormat.report_header(tag: "REMINDER", title: title, style: STYLE_TITLE), message.to_s, IrcFormat.report_footer(*footer_parts) ].join("\n") end
Source
# File plugins/reminder.rb, line 362 def self.format_due(nick:, message:) title = nick.to_s.strip [ IrcFormat.report_header(tag: "REMINDER", title: title, style: STYLE_TITLE), message.to_s, IrcFormat.report_footer("reminder") ].join("\n") end
Source
# File plugins/reminder.rb, line 330 def self.format_duration(seconds) seconds = seconds.to_i return "#{seconds} seconds" if seconds < 60 minutes, remainder = seconds.divmod(60) if remainder.zero? minutes == 1 ? "1 minute" : "#{minutes} minutes" elsif minutes.zero? "#{remainder} seconds" elsif minutes == 1 "1 minute #{remainder} seconds" else "#{minutes} minutes #{remainder} seconds" end end
Source
# File plugins/reminder.rb, line 384 def self.format_list_line(index, entry, now:, utc_offset:, store: RabbotDb) at = store.parse_time(entry["at"]) seconds = at ? (at - now).to_i : entry["seconds"].to_i due_label = at ? at.getlocal(utc_offset).strftime("%H:%M") : "?" "#{index}. #{entry['message']} — in #{format_duration(seconds)} · due #{due_label}" end
Source
# File plugins/reminder.rb, line 391 def self.format_list_report(nick:, entries:, now: Time.now, utc_offset: now.utc_offset, store: RabbotDb) count = entries.length count_label = count == 1 ? "1 reminder" : "#{count} reminders" title = "#{nick} · #{count_label}" body = if entries.empty? "No active reminders." else entries.each_with_index.map do |entry, index| format_list_line(index + 1, entry, now: now, utc_offset: utc_offset, store: store) end.join("\n") end [ IrcFormat.report_header(tag: "REMINDER", title: title, style: STYLE_TITLE), body, IrcFormat.report_footer("reminders") ].join("\n") end
Source
# File plugins/reminder.rb, line 313 def self.format_tz_report(name:, source:, offset:, now: Time.now, footer: "clock times use this zone") label = timezone_short_label(name) local_time = now.getlocal(offset).strftime("%H:%M") source_label = case source when :channel then "channel" when :env then "RABBOT_REMINDER_TZ" when :default then "default" else "?" end title = [label, local_time, source_label].reject(&:empty?).join(" · ") [ IrcFormat.report_header(tag: "REMINDER", title: title, style: STYLE_TITLE), name.to_s, IrcFormat.report_footer(footer) ].join("\n") end
Source
# File plugins/reminder.rb, line 371 def self.list_action?(args) args.to_s.strip.empty? end
Source
# File plugins/reminder.rb, line 170 def self.nearest_future_hour(hour, minute, second, now:, utc_offset:) local_now = now.getlocal(utc_offset) am_hour = hour == 12 ? 0 : hour pm_hour = hour == 12 ? 12 : hour + 12 candidates = [am_hour, pm_hour].uniq.map do |candidate_hour| time = Time.new( local_now.year, local_now.month, local_now.day, candidate_hour, minute, second, utc_offset ) time += 86_400 if time <= now time end candidates.min_by { |time| time - now }.hour end
Source
# File plugins/reminder.rb, line 41 def self.normalize_time_token(text) text.to_s.strip.gsub(/\s+/, " ") end
Source
# File plugins/reminder.rb, line 134 def self.parse_clock_time(text, now: Time.now, utc_offset: now.utc_offset) match = normalize_time_token(text).match(TIME_PATTERN) return nil unless match hour = match[1].to_i minute = (match[2] || "0").to_i second = (match[3] || "0").to_i meridian = match[4]&.downcase if meridian return nil if hour < 1 || hour > 12 || minute > 59 || second > 59 hour = 0 if hour == 12 && meridian == "am" hour = hour % 12 + 12 if meridian == "pm" elsif hour >= 1 && hour <= 12 return nil if minute > 59 || second > 59 hour = nearest_future_hour(hour, minute, second, now: now, utc_offset: utc_offset) elsif hour > 23 || minute > 59 || second > 59 return nil end local_now = now.getlocal(utc_offset) target = Time.new( local_now.year, local_now.month, local_now.day, hour, minute, second, utc_offset ) target += 86_400 if target <= now target end
Source
# File plugins/reminder.rb, line 274 def self.parse_command(text, now: Time.now, utc_offset: now.utc_offset, timezone_name: nil) when_token, message, offset, inline_tz = split_when_and_message(text, now: now, utc_offset: utc_offset) return { error: usage_message } unless when_token resolved = resolve_when(when_token, now: now, utc_offset: offset) return { error: usage_message } unless resolved message = message.to_s.strip return { error: usage_message } if message.empty? seconds = resolved[:seconds] return { error: "Reminder must be in the future." } if seconds <= 0 if seconds > MAX_SECONDS return { error: "Reminder must be within #{format_duration(MAX_SECONDS)}." } end { at: resolved[:at], seconds: seconds, message: message, kind: resolved[:kind], utc_offset: offset, timezone_name: inline_tz || timezone_name } end
Source
# File plugins/reminder.rb, line 190 def self.parse_datetime(text, now: Time.now) value = text.to_s.strip return nil unless value.match?(DATETIME_PATTERN) Time.parse(value) rescue ArgumentError nil end
Source
# File plugins/reminder.rb, line 117 def self.parse_duration(text) match = text.to_s.strip.match(DURATION_PATTERN) return nil unless match value = match[1].to_i unit = match[2].downcase seconds = case unit when "s" then value when "m" then value * 60 when "h" then value * 3600 when "d" then value * 86_400 end return nil if seconds.nil? || seconds <= 0 seconds end
Source
# File plugins/reminder.rb, line 300 def self.parse_tz_command(args) text = args.to_s.strip return { action: :show } if text.casecmp?("tz") match = text.match(/\Atz\s+(.+)\z/i) return { action: :none } unless match name = match[1].to_s.strip return { action: :error, error: "Unknown timezone: #{name}." } unless timezone_to_utc_offset(name) { action: :set, name: name } end
Source
# File plugins/reminder.rb, line 410 def self.pending_entries(network:, channel:, store: RabbotDb) Array(store.get_plugin_json( plugin: PLUGIN, key: KEY, network: network, channel: channel, default: [] )) end
Source
# File plugins/reminder.rb, line 262 def self.pick_better_when_split(current, candidate) return candidate unless current if candidate[:when_end] > current[:when_end] candidate elsif candidate[:when_end] == current[:when_end] && candidate[:seconds] < current[:seconds] candidate else current end end
Source
# File plugins/reminder.rb, line 94 def self.reminder_timezone(network: nil, channel: nil, store: RabbotDb, now: Time.now) if network && channel stored = channel_timezone(network: network, channel: channel, store: store) unless stored.empty? offset = timezone_to_utc_offset(stored, now: now) return { name: stored, source: :channel, offset: offset } unless offset.nil? end end env = ENV.fetch("RABBOT_REMINDER_TZ", "").to_s.strip unless env.empty? offset = timezone_to_utc_offset(env, now: now) return { name: env, source: :env, offset: offset } unless offset.nil? end offset = timezone_to_utc_offset(DEFAULT_TZ, now: now) { name: DEFAULT_TZ, source: :default, offset: offset } end
Source
# File plugins/reminder.rb, line 113 def self.reminder_utc_offset(network: nil, channel: nil, store: RabbotDb, now: Time.now) reminder_timezone(network: network, channel: channel, store: store, now: now)[:offset] end
Source
# File plugins/reminder.rb, line 444 def self.remove_pending(network:, channel:, id:, store: RabbotDb) entries = pending_entries(network: network, channel: channel, store: store) entries.reject! { |entry| entry["id"] == id.to_s } save_pending(network: network, channel: channel, entries: entries, store: store) end
Source
# File plugins/reminder.rb, line 199 def self.resolve_when(token, now: Time.now, utc_offset: now.utc_offset) text = normalize_time_token(token) return nil if text.empty? if (seconds = parse_duration(text)) at = now + seconds return { at: at, seconds: seconds, kind: :duration } end if (at = parse_clock_time(text, now: now, utc_offset: utc_offset)) seconds = (at - now).to_i return { at: at, seconds: seconds, kind: :time } end if (at = parse_datetime(text, now: now)) seconds = (at - now).to_i return { at: at, seconds: seconds, kind: :datetime } end nil end
Source
# File plugins/reminder.rb, line 420 def self.save_pending(network:, channel:, entries:, store: RabbotDb) store.set_plugin_json( plugin: PLUGIN, key: KEY, network: network, channel: channel, value: entries ) end
Source
# File plugins/reminder.rb, line 457 def self.schedule( bot:, network:, channel:, nick:, at:, seconds:, message:, id: SecureRandom.hex(4), store: RabbotDb, run_async: AsyncJob.method(:run) ) add_pending( network: network, channel: channel, nick: nick, message: message, at: at, seconds: seconds, id: id, store: store ) run_async.call(label: "reminder") do sleep seconds if seconds.positive? remove_pending(network: network, channel: channel, id: id, store: store) target = bot.channels.find { |c| c.name.casecmp?(channel.to_s) } target&.send(format_due(nick: nick, message: message)) end end
Source
# File plugins/reminder.rb, line 84 def self.set_channel_timezone(network:, channel:, name:, store: RabbotDb) store.set_plugin_value( plugin: PLUGIN, key: TZ_KEY, network: network, channel: channel, value: name.to_s.strip ) end
Source
# File plugins/reminder.rb, line 221 def self.split_when_and_message(text, now: Time.now, utc_offset: now.utc_offset) words = text.to_s.strip.split(/\s+/) return [nil, "", utc_offset, nil] if words.length < 2 best = nil (1...words.length).each do |when_end| when_words = words[0...when_end] tz_name = nil if when_words.length > 1 maybe_tz = when_words.last if timezone_token?(maybe_tz) && timezone_to_utc_offset(maybe_tz, now: now) tz_name = when_words.pop end end token = normalize_time_token(when_words.join(" ")) next if token.empty? offset = timezone_to_utc_offset(tz_name, now: now) || utc_offset resolved = resolve_when(token, now: now, utc_offset: offset) next unless resolved message = words[when_end..].join(" ").strip next if message.empty? candidate = { token: token, message: message, offset: offset, tz_name: tz_name, when_end: when_end, seconds: resolved[:seconds] } best = pick_better_when_split(best, candidate) end return [nil, "", utc_offset, nil] unless best [best[:token], best[:message], best[:offset], best[:tz_name]] end
Source
# File plugins/reminder.rb, line 68 def self.timezone_short_label(name) token = name.to_s.strip return "" if token.empty? token.include?("/") ? token.split("/").last.tr("_", " ") : token end
Source
# File plugins/reminder.rb, line 52 def self.timezone_to_utc_offset(name, now: Time.now) token = name.to_s.strip return nil if token.empty? if (match = token.match(/\A([+-])(\d{1,2})(?::(\d{2}))?\z/)) sign = match[1] == "-" ? -1 : 1 hours = match[2].to_i minutes = (match[3] || "0").to_i return sign * ((hours * 3600) + (minutes * 60)) end TZInfo::Timezone.get(token).period_for_utc(now.utc).utc_offset rescue TZInfo::InvalidTimezoneIdentifier nil end
Source
# File plugins/reminder.rb, line 45 def self.timezone_token?(text) token = text.to_s.strip return false if token.empty? token.match?(TIMEZONE_PATTERN) end
Source
# File plugins/reminder.rb, line 37 def self.usage_message "Usage: !reminder <time|datetime|duration> <message> — e.g. !reminder 5m check BOM, !reminder 8:00pm wake up" end
Public Instance Methods
Source
# File plugins/reminder.rb, line 500 def execute(m, args = nil) unless m.channel themed_flood_safe_reply(m, "Reminders must be set from a channel.") return end network = bot.config.server channel = m.channel.name tz_config = self.class.reminder_timezone(network: network, channel: channel) if self.class.list_action?(args) entries = self.class.active_pending_for( nick: m.user.nick, network: network, channel: channel ) themed_flood_safe_reply( m, self.class.format_list_report( nick: m.user.nick, entries: entries, utc_offset: tz_config[:offset] ) ) return end tz_parsed = self.class.parse_tz_command(args) case tz_parsed[:action] when :show themed_flood_safe_reply( m, self.class.format_tz_report( name: tz_config[:name], source: tz_config[:source], offset: tz_config[:offset] ) ) return when :set self.class.set_channel_timezone( network: network, channel: channel, name: tz_parsed[:name] ) tz_config = self.class.reminder_timezone(network: network, channel: channel) themed_flood_safe_reply( m, self.class.format_tz_report( name: tz_config[:name], source: tz_config[:source], offset: tz_config[:offset], footer: "channel timezone set" ) ) return when :error themed_flood_safe_reply(m, tz_parsed[:error]) return end parsed = self.class.parse_command( args, utc_offset: tz_config[:offset], timezone_name: tz_config[:name] ) if parsed[:error] themed_flood_safe_reply(m, parsed[:error]) return end self.class.schedule( bot: bot, network: network, channel: channel, nick: m.user.nick, at: parsed[:at], seconds: parsed[:seconds], message: parsed[:message] ) themed_flood_safe_reply( m, self.class.format_ack( nick: m.user.nick, message: parsed[:message], at: parsed[:at], now: Time.now, utc_offset: parsed[:utc_offset], timezone_name: parsed[:timezone_name] ) ) end
Source
# File plugins/reminder.rb, line 488 def on_connect(_m) network = bot.config.server bot.config.channels.each do |channel| self.class.due_pending(network: network, channel: channel).each do |entry| bot.channels.find { |c| c.name.casecmp?(channel.to_s) }&.send( self.class.format_due(nick: entry["nick"], message: entry["message"]) ) self.class.remove_pending(network: network, channel: channel, id: entry["id"]) end end end