123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318 |
- // This file is subject to a 1-clause BSD license.
- // Its contents can be found in the enclosed LICENSE file.
- // Package reminder allows a user to schedule a reminder with a custom message.
- // The reminder can be scheduled at an exact time or an offset from the
- // current time. Once a scheduled reminder's time has come, the bot will notify
- // the user who scheduled it. Reminders can be unscheduled by the user who
- // scheduled it.
- //
- // Create a new reminder for 10 minutes from now:
- //
- // <steve> !reminder 10 Make food.
- //
- // Create a new reminder for 18:15:
- //
- // <steve> !reminder 18:15 Make food.
- //
- package reminder
- import (
- "math/rand"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
- "notabug.org/mouz/bot/app/util"
- "notabug.org/mouz/bot/irc"
- "notabug.org/mouz/bot/irc/cmd"
- "notabug.org/mouz/bot/irc/proto"
- "notabug.org/mouz/bot/plugins"
- )
- func init() { plugins.Register(&plugin{}) }
- // reminder defines a single scheduled reminder.
- type reminder struct {
- SenderMask string
- SenderName string
- Target string
- Message string
- When time.Time
- }
- type plugin struct {
- m sync.RWMutex
- file string
- cmd *cmd.Set
- table map[string]reminder
- quitOnce sync.Once
- quit chan struct{}
- }
- // Load initializes the module and loads any internal resources
- // which may be required.
- func (p *plugin) Load(prof irc.Profile) error {
- p.quit = make(chan struct{})
- p.table = make(map[string]reminder)
- p.file = filepath.Join(prof.Root(), "reminder.gz")
- p.cmd = cmd.New(prof.CommandPrefix(), nil)
- p.cmd.Bind(TextReminder, false, p.onReminder).
- Add(TextTimestamp, true, cmd.RegAny).
- Add(TextMessage, false, cmd.RegAny)
- p.cmd.Bind(TextReminder2, false, p.onReminder).
- Add(TextTimestamp, true, cmd.RegAny).
- Add(TextMessage, false, cmd.RegAny)
- p.cmd.Bind(TextClearReminder, false, p.onClearReminder).
- Add(TextID, true, cmd.RegAny)
- p.cmd.Bind(TextClearReminder2, false, p.onClearReminder).
- Add(TextID, true, cmd.RegAny)
- p.cmd.Bind(TextReminderList, false, p.onReminderList)
- p.cmd.Bind(TextReminderList2, false, p.onReminderList)
- go p.pollReminders()
- return util.ReadFile(p.file, &p.table, true)
- }
- // Unload cleans the module up and unloads any internal resources.
- func (p *plugin) Unload(prof irc.Profile) error {
- p.quitOnce.Do(func() {
- close(p.quit)
- })
- return nil
- }
- // Dispatch sends the given, incoming IRC message to the plugin for
- // processing as it sees fit.
- func (p *plugin) Dispatch(w irc.ResponseWriter, r *irc.Request) {
- p.cmd.Dispatch(w, r)
- }
- // onReminder lets a user schedule a new reminder.
- func (p *plugin) onReminder(w irc.ResponseWriter, r *irc.Request, params cmd.ParamList) {
- id := p.createID()
- if !p.addReminder(w, r, params, id) {
- p.deleteID(id)
- }
- }
- // onClearReminder lets a user remove an existing reminder.
- func (p *plugin) onClearReminder(w irc.ResponseWriter, r *irc.Request, params cmd.ParamList) {
- id := strings.ToLower(params.String(0))
- p.m.Lock()
- a, ok := p.table[id]
- if ok {
- if strings.EqualFold(a.SenderMask, r.SenderMask) {
- delete(p.table, id)
- proto.PrivMsg(w, r.Target, TextReminderUnset, r.SenderName)
- util.WriteFile(p.file, p.table, true)
- } else {
- proto.PrivMsg(w, r.Target, TextNoRemoval, r.SenderName, a.SenderMask)
- }
- } else {
- proto.PrivMsg(w, r.Target, TextNoId, r.SenderName, id)
- }
- p.m.Unlock()
- }
- // onReminderList lists a user's active reminders. The list of
- // reminders is sorted according to there time (ascending).
- func (p *plugin) onReminderList(w irc.ResponseWriter, r *irc.Request, params cmd.ParamList) {
- count := 0
- // create a time sorted table of ids
- type kv struct {
- key string
- value time.Time
- }
- var ss []kv
- for id, reminder := range p.table {
- ss = append(ss, kv{id, reminder.When})
- }
- sort.Slice(ss, func(i, j int) bool {
- return ss[i].value.Before(ss[j].value)
- })
- // loop through time sorted table to list reminders
- for _, kv := range ss {
- id := kv.key
- reminder := p.table[id]
- if strings.EqualFold(reminder.SenderMask, r.SenderMask) ||
- strings.EqualFold(reminder.SenderName, r.SenderName) {
- proto.PrivMsg(w, r.Target, TextReminderItem,
- reminder.When.Format(TextTimeFormat2), id, reminder.Message)
- count++
- }
- }
- if count == 0 {
- proto.PrivMsg(w, r.Target, TextNoReminders, r.SenderName)
- }
- }
- // addReminder does what the docs on addReminder describe. This is a separate
- // method with the unique id as added parameter to make unit test code
- // easier to write. This returns false if the reminder was not scheduled.
- // This can happen when the tim value is invalid. If this is the case, the
- // given id should either be removed from the table, or reused.
- func (p *plugin) addReminder(w irc.ResponseWriter, r *irc.Request, params cmd.ParamList, id string) bool {
- when := parseTime(params.String(0))
- if when <= 0 {
- proto.PrivMsg(w, r.Target, TextInvalidTime, r.SenderName, params.String(0))
- return false
- }
- p.m.Lock()
- p.table[id] = reminder{
- Target: r.Target,
- SenderMask: r.SenderMask,
- SenderName: r.SenderName,
- Message: strings.Join(r.Fields(2), " "),
- When: time.Now().Add(when),
- }
- util.WriteFile(p.file, p.table, true)
- p.m.Unlock()
- whenfmt := p.table[id].When.Format(TextTimeFormat3)
- proto.PrivMsg(w, r.Target, TextReminderSet, r.SenderName, whenfmt, util.Bold(id))
- return true
- }
- // pollReminders periodically checks if any of the defined reminders have expired.
- func (p *plugin) pollReminders() {
- for {
- select {
- case <-p.quit:
- return
- case <-time.After(time.Minute):
- p.checkExpiredReminders()
- }
- }
- }
- // deleteID removes the given id from the table, if it exists.
- func (p *plugin) deleteID(id string) {
- p.m.Lock()
- delete(p.table, id)
- p.m.Unlock()
- }
- // createID returns a new, unique id for a reminder. This id can be used as
- // a cancellation code. Note that this call will create a new table entry
- // with the given id, so subsequent calls to createID() will not accidentally
- // re-use the generated one before the caller can.
- func (p *plugin) createID() string {
- p.m.Lock()
- defer p.m.Unlock()
- // prevents endless loop below. 17575 = 26*26*26 - 1
- if len(p.table) > 17575 {
- p.table["E01"] = reminder{}
- return "E01"
- }
- var key [3]byte
- const alphabet = "abcdefghijklmnopqrstuvwxyz"
- rng := rand.New(rand.NewSource(time.Now().UnixNano()))
- var generate = func() string {
- for i := 0; i < len(key); i++ {
- key[i] = alphabet[rng.Intn(len(alphabet))]
- }
- return string(key[:])
- }
- id := generate()
- for {
- if _, ok := p.table[id]; !ok {
- break
- }
- id = generate()
- }
- p.table[id] = reminder{}
- return id
- }
- // checkExpiredReminders checks for expired reminders.
- // When found, it sends the appropriate notification.
- func (p *plugin) checkExpiredReminders() {
- p.m.Lock()
- defer p.m.Unlock()
- now := time.Now()
- c := irc.Connection
- if c == nil {
- return
- }
- for id, reminder := range p.table {
- if now.Before(reminder.When) {
- continue
- }
- msg := TextDefaultMessage
- if len(reminder.Message) != 0 {
- msg = TextMessagePrefix + reminder.Message
- }
- proto.PrivMsg(c, reminder.Target, msg,
- reminder.SenderName, time.Now().Format(TextTimeFormat))
- delete(p.table, id)
- util.WriteFile(p.file, p.table, true)
- }
- }
- // parseTime treats the given value as either an absolute time, or
- // an offset in minutes. It returns the value which represents the
- // duration between now and then.
- func parseTime(v string) time.Duration {
- then, err := time.Parse(TextTimeFormat, v)
- if err == nil {
- // We expect the given time to include only the time.
- // We must set the date components manually.
- now := time.Now()
- then = time.Date(now.Year(), now.Month(), now.Day(),
- then.Hour(), then.Minute(), 0, 0, now.Location())
- delta := then.Sub(now)
- // If delta is negative, we are probably dealing with a time which
- // is meant to mean 'tomorrow'. So add 24 hours to the clock and
- // recalculate the difference.
- if delta < 0 {
- then = then.Add(time.Hour * 24)
- delta = then.Sub(now)
- }
- return delta
- }
- // If not an absolute time, the value is expected to be an offset
- // in minutes from the current time.
- num, err := strconv.ParseInt(v, 10, 32)
- if err == nil {
- // This can result in a negative duration, if someone specified
- // "-10" as the input. This is an error which is caught by the caller.
- return time.Duration(num) * time.Minute
- }
- return 0
- }
|