plugin.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. // This file is subject to a 1-clause BSD license.
  2. // Its contents can be found in the enclosed LICENSE file.
  3. // Package reminder allows a user to schedule a reminder with a custom message.
  4. // The reminder can be scheduled at an exact time or an offset from the
  5. // current time. Once a scheduled reminder's time has come, the bot will notify
  6. // the user who scheduled it. Reminders can be unscheduled by the user who
  7. // scheduled it.
  8. //
  9. // Create a new reminder for 10 minutes from now:
  10. //
  11. // <steve> !reminder 10 Make food.
  12. //
  13. // Create a new reminder for 18:15:
  14. //
  15. // <steve> !reminder 18:15 Make food.
  16. //
  17. package reminder
  18. import (
  19. "math/rand"
  20. "path/filepath"
  21. "sort"
  22. "strconv"
  23. "strings"
  24. "sync"
  25. "time"
  26. "notabug.org/mouz/bot/app/util"
  27. "notabug.org/mouz/bot/irc"
  28. "notabug.org/mouz/bot/irc/cmd"
  29. "notabug.org/mouz/bot/irc/proto"
  30. "notabug.org/mouz/bot/plugins"
  31. )
  32. func init() { plugins.Register(&plugin{}) }
  33. // reminder defines a single scheduled reminder.
  34. type reminder struct {
  35. SenderMask string
  36. SenderName string
  37. Target string
  38. Message string
  39. When time.Time
  40. }
  41. type plugin struct {
  42. m sync.RWMutex
  43. file string
  44. cmd *cmd.Set
  45. table map[string]reminder
  46. quitOnce sync.Once
  47. quit chan struct{}
  48. }
  49. // Load initializes the module and loads any internal resources
  50. // which may be required.
  51. func (p *plugin) Load(prof irc.Profile) error {
  52. p.quit = make(chan struct{})
  53. p.table = make(map[string]reminder)
  54. p.file = filepath.Join(prof.Root(), "reminder.gz")
  55. p.cmd = cmd.New(prof.CommandPrefix(), nil)
  56. p.cmd.Bind(TextReminder, false, p.onReminder).
  57. Add(TextTimestamp, true, cmd.RegAny).
  58. Add(TextMessage, false, cmd.RegAny)
  59. p.cmd.Bind(TextReminder2, false, p.onReminder).
  60. Add(TextTimestamp, true, cmd.RegAny).
  61. Add(TextMessage, false, cmd.RegAny)
  62. p.cmd.Bind(TextClearReminder, false, p.onClearReminder).
  63. Add(TextID, true, cmd.RegAny)
  64. p.cmd.Bind(TextClearReminder2, false, p.onClearReminder).
  65. Add(TextID, true, cmd.RegAny)
  66. p.cmd.Bind(TextReminderList, false, p.onReminderList)
  67. p.cmd.Bind(TextReminderList2, false, p.onReminderList)
  68. go p.pollReminders()
  69. return util.ReadFile(p.file, &p.table, true)
  70. }
  71. // Unload cleans the module up and unloads any internal resources.
  72. func (p *plugin) Unload(prof irc.Profile) error {
  73. p.quitOnce.Do(func() {
  74. close(p.quit)
  75. })
  76. return nil
  77. }
  78. // Dispatch sends the given, incoming IRC message to the plugin for
  79. // processing as it sees fit.
  80. func (p *plugin) Dispatch(w irc.ResponseWriter, r *irc.Request) {
  81. p.cmd.Dispatch(w, r)
  82. }
  83. // onReminder lets a user schedule a new reminder.
  84. func (p *plugin) onReminder(w irc.ResponseWriter, r *irc.Request, params cmd.ParamList) {
  85. id := p.createID()
  86. if !p.addReminder(w, r, params, id) {
  87. p.deleteID(id)
  88. }
  89. }
  90. // onClearReminder lets a user remove an existing reminder.
  91. func (p *plugin) onClearReminder(w irc.ResponseWriter, r *irc.Request, params cmd.ParamList) {
  92. id := strings.ToLower(params.String(0))
  93. p.m.Lock()
  94. a, ok := p.table[id]
  95. if ok {
  96. if strings.EqualFold(a.SenderMask, r.SenderMask) {
  97. delete(p.table, id)
  98. proto.PrivMsg(w, r.Target, TextReminderUnset, r.SenderName)
  99. util.WriteFile(p.file, p.table, true)
  100. } else {
  101. proto.PrivMsg(w, r.Target, TextNoRemoval, r.SenderName, a.SenderMask)
  102. }
  103. } else {
  104. proto.PrivMsg(w, r.Target, TextNoId, r.SenderName, id)
  105. }
  106. p.m.Unlock()
  107. }
  108. // onReminderList lists a user's active reminders. The list of
  109. // reminders is sorted according to there time (ascending).
  110. func (p *plugin) onReminderList(w irc.ResponseWriter, r *irc.Request, params cmd.ParamList) {
  111. count := 0
  112. // create a time sorted table of ids
  113. type kv struct {
  114. key string
  115. value time.Time
  116. }
  117. var ss []kv
  118. for id, reminder := range p.table {
  119. ss = append(ss, kv{id, reminder.When})
  120. }
  121. sort.Slice(ss, func(i, j int) bool {
  122. return ss[i].value.Before(ss[j].value)
  123. })
  124. // loop through time sorted table to list reminders
  125. for _, kv := range ss {
  126. id := kv.key
  127. reminder := p.table[id]
  128. if strings.EqualFold(reminder.SenderMask, r.SenderMask) ||
  129. strings.EqualFold(reminder.SenderName, r.SenderName) {
  130. proto.PrivMsg(w, r.Target, TextReminderItem,
  131. reminder.When.Format(TextTimeFormat2), id, reminder.Message)
  132. count++
  133. }
  134. }
  135. if count == 0 {
  136. proto.PrivMsg(w, r.Target, TextNoReminders, r.SenderName)
  137. }
  138. }
  139. // addReminder does what the docs on addReminder describe. This is a separate
  140. // method with the unique id as added parameter to make unit test code
  141. // easier to write. This returns false if the reminder was not scheduled.
  142. // This can happen when the tim value is invalid. If this is the case, the
  143. // given id should either be removed from the table, or reused.
  144. func (p *plugin) addReminder(w irc.ResponseWriter, r *irc.Request, params cmd.ParamList, id string) bool {
  145. when := parseTime(params.String(0))
  146. if when <= 0 {
  147. proto.PrivMsg(w, r.Target, TextInvalidTime, r.SenderName, params.String(0))
  148. return false
  149. }
  150. p.m.Lock()
  151. p.table[id] = reminder{
  152. Target: r.Target,
  153. SenderMask: r.SenderMask,
  154. SenderName: r.SenderName,
  155. Message: strings.Join(r.Fields(2), " "),
  156. When: time.Now().Add(when),
  157. }
  158. util.WriteFile(p.file, p.table, true)
  159. p.m.Unlock()
  160. whenfmt := p.table[id].When.Format(TextTimeFormat3)
  161. proto.PrivMsg(w, r.Target, TextReminderSet, r.SenderName, whenfmt, util.Bold(id))
  162. return true
  163. }
  164. // pollReminders periodically checks if any of the defined reminders have expired.
  165. func (p *plugin) pollReminders() {
  166. for {
  167. select {
  168. case <-p.quit:
  169. return
  170. case <-time.After(time.Minute):
  171. p.checkExpiredReminders()
  172. }
  173. }
  174. }
  175. // deleteID removes the given id from the table, if it exists.
  176. func (p *plugin) deleteID(id string) {
  177. p.m.Lock()
  178. delete(p.table, id)
  179. p.m.Unlock()
  180. }
  181. // createID returns a new, unique id for a reminder. This id can be used as
  182. // a cancellation code. Note that this call will create a new table entry
  183. // with the given id, so subsequent calls to createID() will not accidentally
  184. // re-use the generated one before the caller can.
  185. func (p *plugin) createID() string {
  186. p.m.Lock()
  187. defer p.m.Unlock()
  188. // prevents endless loop below. 17575 = 26*26*26 - 1
  189. if len(p.table) > 17575 {
  190. p.table["E01"] = reminder{}
  191. return "E01"
  192. }
  193. var key [3]byte
  194. const alphabet = "abcdefghijklmnopqrstuvwxyz"
  195. rng := rand.New(rand.NewSource(time.Now().UnixNano()))
  196. var generate = func() string {
  197. for i := 0; i < len(key); i++ {
  198. key[i] = alphabet[rng.Intn(len(alphabet))]
  199. }
  200. return string(key[:])
  201. }
  202. id := generate()
  203. for {
  204. if _, ok := p.table[id]; !ok {
  205. break
  206. }
  207. id = generate()
  208. }
  209. p.table[id] = reminder{}
  210. return id
  211. }
  212. // checkExpiredReminders checks for expired reminders.
  213. // When found, it sends the appropriate notification.
  214. func (p *plugin) checkExpiredReminders() {
  215. p.m.Lock()
  216. defer p.m.Unlock()
  217. now := time.Now()
  218. c := irc.Connection
  219. if c == nil {
  220. return
  221. }
  222. for id, reminder := range p.table {
  223. if now.Before(reminder.When) {
  224. continue
  225. }
  226. msg := TextDefaultMessage
  227. if len(reminder.Message) != 0 {
  228. msg = TextMessagePrefix + reminder.Message
  229. }
  230. proto.PrivMsg(c, reminder.Target, msg,
  231. reminder.SenderName, time.Now().Format(TextTimeFormat))
  232. delete(p.table, id)
  233. util.WriteFile(p.file, p.table, true)
  234. }
  235. }
  236. // parseTime treats the given value as either an absolute time, or
  237. // an offset in minutes. It returns the value which represents the
  238. // duration between now and then.
  239. func parseTime(v string) time.Duration {
  240. then, err := time.Parse(TextTimeFormat, v)
  241. if err == nil {
  242. // We expect the given time to include only the time.
  243. // We must set the date components manually.
  244. now := time.Now()
  245. then = time.Date(now.Year(), now.Month(), now.Day(),
  246. then.Hour(), then.Minute(), 0, 0, now.Location())
  247. delta := then.Sub(now)
  248. // If delta is negative, we are probably dealing with a time which
  249. // is meant to mean 'tomorrow'. So add 24 hours to the clock and
  250. // recalculate the difference.
  251. if delta < 0 {
  252. then = then.Add(time.Hour * 24)
  253. delta = then.Sub(now)
  254. }
  255. return delta
  256. }
  257. // If not an absolute time, the value is expected to be an offset
  258. // in minutes from the current time.
  259. num, err := strconv.ParseInt(v, 10, 32)
  260. if err == nil {
  261. // This can result in a negative duration, if someone specified
  262. // "-10" as the input. This is an error which is caught by the caller.
  263. return time.Duration(num) * time.Minute
  264. }
  265. return 0
  266. }