saver.lisp 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. (in-package #:chatikbot)
  2. (defsetting *saver-default-timezone* 3 "Default timezone for *saver-notify-hour* calculation. GMT+3")
  3. (defsetting *saver-notify-hour* 11 "Notify with upcoming payments and saves at this time")
  4. (defstruct saver/payment name amount schedule started)
  5. (defun saver/parse-schedule (schedule)
  6. (labels ((parse (text &optional def-min def-max)
  7. (let ((dash-pos (position #\- text))
  8. (star-pos (position #\* text))
  9. (slash-pos (position #\/ text)))
  10. (if (or dash-pos star-pos slash-pos)
  11. (let ((min (if star-pos def-min
  12. (when dash-pos (parse-integer text :end dash-pos))))
  13. (max (if star-pos def-max
  14. (when dash-pos (parse-integer text :start (1+ dash-pos) :end slash-pos))))
  15. (step (or (when slash-pos (parse-integer text :start (1+ slash-pos))) 1)))
  16. (list nil min max step))
  17. (list (sort (mapcar #'parse-integer (split-sequence:split-sequence #\, text)) #'<))))))
  18. (destructuring-bind (&optional day-sched month-sched year-sched)
  19. (split-sequence:split-sequence #\Space schedule :remove-empty-subseqs t)
  20. (list (if day-sched (parse day-sched 1 31) (list nil 1 31))
  21. (if month-sched (parse month-sched 1 12) (list nil 1 12))
  22. (if year-sched (parse year-sched 1900) (list nil 1900))))))
  23. (defun saver/get-next-time (schedule universal-time &optional (dir t))
  24. (labels ((leapp (year)
  25. (or (and (zerop (mod year 4))
  26. (not (zerop (mod year 100))))
  27. (zerop (mod year 400))))
  28. (clamp-day-rule (day-rule month year)
  29. (destructuring-bind (lst &optional (min 0) max (step 1)) day-rule
  30. (let ((max-day (case month
  31. (2 (if (leapp year) 29 28))
  32. ((1 3 5 7 8 10 12) 31)
  33. (otherwise 30))))
  34. (if (consp lst)
  35. (list (remove-duplicates (mapcar (lambda (d) (min d max-day)) lst)))
  36. (list nil (min min) (when max (min max max-day)) step)))))
  37. (next (from rule)
  38. (destructuring-bind (lst &optional (min 0) max (step 1)) rule
  39. (if (consp lst)
  40. (let ((nv (find from lst :test (if dir #'<= #'>=) :from-end (not dir))))
  41. (if nv
  42. (values nv nil)
  43. (values (car (if dir lst (last lst))) t)))
  44. (let* ((m (mod (- from min) step))
  45. (nv (if (zerop m) from
  46. (funcall (if dir #'+ #'-) from
  47. (if dir (- step m) m)))))
  48. (cond
  49. ((and max (> nv max))
  50. (if dir
  51. (values min t)
  52. (values max nil)))
  53. ((and min (< nv min))
  54. (if dir
  55. (values min nil)
  56. (values max t)))
  57. (t (values nv nil)))))))
  58. (add (v of)
  59. (funcall (if dir #'+ #'-) v (if of 1 0)))
  60. (reset-rule (rule)
  61. (destructuring-bind (lst &optional (min 0) max (step 1)) rule
  62. (if (consp lst)
  63. (car (if dir lst (last lst)))
  64. (if dir min
  65. (let ((m (mod (- max min) step)))
  66. (- max (if (zerop m) 0 m))))))))
  67. (multiple-value-bind (second minute hour day month year dow dst-p tz) (decode-universal-time universal-time)
  68. (declare (ignore second minute hour dow dst-p tz))
  69. (destructuring-bind (&optional (day-rule '(nil 1 31)) (month-rule '(nil 1 12)) (year-rule '(nil 1900)))
  70. schedule
  71. (multiple-value-bind (next-day of-day) (next (add day (not dir))
  72. (clamp-day-rule day-rule month year))
  73. (multiple-value-bind (next-month of-month) (next (add month of-day) month-rule)
  74. (multiple-value-bind (next-year of-year) (next (add year of-month) year-rule)
  75. (unless of-year
  76. (let ((next-month (if (= next-year year) next-month (reset-rule month-rule))))
  77. (encode-universal-time
  78. 0 0 0
  79. (if (and (= next-month month) (= next-year year)) next-day
  80. (reset-rule (clamp-day-rule day-rule next-month next-year)))
  81. next-month
  82. next-year))))))))))
  83. (defun saver/count-events (from to schedule)
  84. (loop for ut = (saver/get-next-time schedule from) then (saver/get-next-time
  85. schedule (+ ut 86400))
  86. while (< ut to)
  87. counting ut))
  88. (defun saver/get-payment-info (payment salary &optional (moment (get-universal-time)))
  89. (let* ((salary (saver/parse-schedule salary))
  90. (schedule (saver/parse-schedule (saver/payment-schedule payment)))
  91. (next-payment (saver/get-next-time schedule moment))
  92. (prev-payment (max (saver/get-next-time schedule moment nil)
  93. (or (saver/payment-started payment) 0))))
  94. (when next-payment
  95. (let* ((total-periods (saver/count-events prev-payment next-payment salary))
  96. (saved-periods (saver/count-events prev-payment moment salary))
  97. (period-amount (floor (saver/payment-amount payment) (max 1 total-periods)))
  98. (saved-amount (* saved-periods period-amount)))
  99. (list :next-payment next-payment
  100. :prev-payment prev-payment
  101. :total-periods total-periods
  102. :period-amount period-amount
  103. :saved-periods saved-periods
  104. :saved-amount saved-amount
  105. :left-periods (- total-periods saved-periods)
  106. :left-amount (- (saver/payment-amount payment) saved-amount))))))
  107. (defun saver/get-period-info (salary payments &optional (moment (get-universal-time)))
  108. (let ((next-salary (saver/get-next-time (saver/parse-schedule salary) moment)))
  109. (when next-salary
  110. (loop for payment in payments
  111. for cur-info = (saver/get-payment-info payment salary moment)
  112. for next-info = (saver/get-payment-info payment salary next-salary)
  113. summing (getf cur-info :saved-amount) into total-saved
  114. summing (getf cur-info :left-amount) into total-left
  115. summing (if (> (getf next-info :left-periods) 1)
  116. (getf next-info :period-amount)
  117. (getf next-info :left-amount)) into total-period
  118. finally (return
  119. (list :next-salary next-salary
  120. :total-saved total-saved
  121. :total-left total-left
  122. :total-period total-period))))))
  123. (defun %saver/format-time (universal-time)
  124. (when universal-time
  125. (multiple-value-bind (sec min hour day month year dow dst-p tz)
  126. (decode-universal-time universal-time)
  127. (declare (ignore sec min hour dow dst-p tz))
  128. (format nil "~4,'0D-~2,'0D-~2,'0D" year month day))))
  129. ;; Database
  130. (def-db-init
  131. (db-execute "create table if not exists saver_payments (chat_id, name, amount, schedule, started, notified)")
  132. (db-execute "create unique index if not exists saver_payments_chat_id_name_idx on saver_payments (chat_id, name)")
  133. (db-execute "create table if not exists saver_salaries (chat_id, schedule, notified)")
  134. (db-execute "create unique index if not exists saver_salaries_chat_id_idx on saver_salaries (chat_id)"))
  135. (defun %db/saver/make-payment (row)
  136. (when row
  137. (make-saver/payment :name (nth 0 row)
  138. :amount (nth 1 row)
  139. :schedule (nth 2 row)
  140. :started (nth 3 row))))
  141. (defun db/saver/get-payments (chat-id)
  142. (mapcar #'%db/saver/make-payment
  143. (db-select "select name, amount, schedule, started from saver_payments where chat_id = ? order by started" chat-id)))
  144. (defun db/saver/get-not-notified-payments (notified)
  145. (loop for row in (db-select "select name, amount, schedule, started, chat_id from saver_payments where notified is null or notified != ?" (%saver/format-time notified))
  146. collect (cons (nth 4 row) (%db/saver/make-payment row))))
  147. (defun db/saver/set-payment-notified (chat-id name moment)
  148. (db-execute "update saver_payments set notified = ? where chat_id = ? and name = ?"
  149. (%saver/format-time moment) chat-id name))
  150. (defun db/saver/add-payment (chat-id payment)
  151. (with-slots (name amount schedule started) payment
  152. (db-execute "insert into saver_payments (chat_id, name, amount, schedule, started) values (?, ?, ?, ?, ?)"
  153. chat-id name amount schedule started)))
  154. (defun db/saver/del-payment (chat-id name)
  155. (db-execute "delete from saver_payments where chat_id = ? and name = ?" chat-id name))
  156. (defun db/saver/get-salary (chat-id)
  157. (caar (db-select "select schedule from saver_salaries where chat_id = ?" chat-id)))
  158. (defun db/saver/get-not-notified-salaries (notified)
  159. (loop for row in (db-select "select chat_id, schedule from saver_salaries where notified is null or notified != ?" (%saver/format-time notified))
  160. collect (cons (nth 0 row) (nth 1 row))))
  161. (defun db/saver/set-salary-notified (chat-id moment)
  162. (db-execute "update saver_salaries set notified = ? where chat_id = ?"
  163. (%saver/format-time moment) chat-id))
  164. (defun db/saver/set-salary (chat-id schedule)
  165. (db-transaction
  166. (db-execute "delete from saver_salaries where chat_id = ?" chat-id)
  167. (db-execute "insert into saver_salaries (chat_id, schedule) values (?, ?)" chat-id schedule)))
  168. ;; Cron
  169. (defun saver/find-today-payments (&optional (moment (get-universal-time)))
  170. (remove-if-not (lambda (p) (string= (%saver/format-time moment)
  171. (%saver/format-time
  172. (saver/get-next-time
  173. (saver/parse-schedule (saver/payment-schedule p))
  174. moment))))
  175. (db/saver/get-not-notified-payments moment)
  176. :key #'cdr))
  177. (defun %saver/format-payment-notification (payment moment)
  178. (format nil "'~A' надо оплатить *~A* на сумму _~$_"
  179. (saver/payment-name payment)
  180. (%saver/format-time moment)
  181. (/ (saver/payment-amount payment) 100)))
  182. (defun saver/find-today-salaries (&optional (moment (get-universal-time)))
  183. (remove-if-not (lambda (p) (string= (%saver/format-time moment)
  184. (%saver/format-time (saver/get-next-time
  185. (saver/parse-schedule p) moment))))
  186. (db/saver/get-not-notified-salaries moment)
  187. :key #'cdr))
  188. (defun %saver/format-salary-notification (salary payments moment)
  189. (let ((period-info (saver/get-period-info salary payments moment)))
  190. (format nil "*~A* зарплата. Отложи _~$_!"
  191. (%saver/format-time (getf period-info :next-salary))
  192. (/ (getf period-info :total-period) 100))))
  193. (defcron process-saver (:hour '*)
  194. (let* ((moment (get-universal-time))
  195. (hour (nth 2 (multiple-value-list (decode-universal-time moment *saver-default-timezone*)))))
  196. (when (>= hour *saver-notify-hour*)
  197. (dolist (cp (saver/find-today-payments moment))
  198. (db-transaction
  199. (bot-send-message (car cp) (%saver/format-payment-notification (cdr cp) moment) :parse-mode "markdown")
  200. (db/saver/set-payment-notified (car cp) (saver/payment-name (cdr cp)) moment)))
  201. (dolist (cs (saver/find-today-salaries moment))
  202. (db-transaction
  203. (bot-send-message (car cs)
  204. (%saver/format-salary-notification (cdr cs)
  205. (db/saver/get-payments (car cs))
  206. moment)
  207. :parse-mode "markdown")
  208. (db/saver/set-salary-notified (car cs) moment))))))
  209. ;; Bot subcommands
  210. (defun %saver/format-info (payments salary &optional (moment (get-universal-time)))
  211. (let* (closest-time
  212. closest-payment
  213. (payments-info
  214. (loop for payment in payments
  215. for idx from 1
  216. for info = (saver/get-payment-info payment salary moment)
  217. when info
  218. when (or (null closest-time)
  219. (< (getf info :next-payment) closest-time))
  220. do (setf closest-time (getf info :next-payment)
  221. closest-payment payment)
  222. collect (format nil "~D) ~A по [[~A]]: накоплено _~$_ из _~$_, осталось _~$_ за ~A платежа"
  223. idx
  224. (saver/payment-name payment)
  225. (saver/payment-schedule payment)
  226. (/ (getf info :saved-amount) 100)
  227. (/ (saver/payment-amount payment) 100)
  228. (/ (getf info :left-amount) 100)
  229. (getf info :left-periods))))
  230. (period-info (saver/get-period-info salary payments moment)))
  231. (if payments-info
  232. (format nil "*Платежи*~%~{~A~^~%~}~%~%Накоплено должно быть _~$_~%Следующее накопление *~A* на _~$_~%Следующий платёж '~A' *~A*, _~$_"
  233. payments-info
  234. (/ (getf period-info :total-saved) 100)
  235. (%saver/format-time (getf period-info :next-salary))
  236. (/ (getf period-info :total-period) 100)
  237. (saver/payment-name closest-payment)
  238. (%saver/format-time closest-time)
  239. (/ (saver/payment-amount closest-payment) 100))
  240. "Нет активных платежей.")))
  241. (defun saver/send-info (chat-id)
  242. (let ((payments (db/saver/get-payments chat-id))
  243. (salary (db/saver/get-salary chat-id)))
  244. (if salary
  245. (if payments
  246. (bot-send-message chat-id (%saver/format-info payments salary) :parse-mode "markdown")
  247. (bot-send-message chat-id (format nil "Платежей нет, зарплата: ~A" salary)))
  248. (send-response chat-id "Зарплата не задана, /saver salary <cron>"))))
  249. (defun saver/set-salary (chat-id args)
  250. (let ((sched (spaced args)))
  251. (saver/parse-schedule sched)
  252. (db/saver/set-salary chat-id sched)
  253. (send-response chat-id (format nil "Зарплата теперь '~A'" sched))))
  254. (defparameter +saver/add-scanner+ (cl-ppcre:create-scanner "^(.+?) (\\d+(?:\\.\\d*)?) ((?:\\d+(?:,\\d+)*(?:-\\d+)?|\\*)(?:\\/\\d+)?(?: (?:\\d+(?:,\\d+)*(?:-\\d+)?|\\*)(?:\\/\\d+)?){0,2})(?: (\\d{4}-\\d{2}-\\d{2}))?$"))
  255. (defun saver/add-payment (chat-id args)
  256. (multiple-value-bind (matched groups) (cl-ppcre:scan-to-strings +saver/add-scanner+ (spaced args))
  257. (if matched
  258. (let ((payment (make-saver/payment :name (elt groups 0)
  259. :amount (round (* 100 (read-from-string (elt groups 1))))
  260. :schedule (elt groups 2)
  261. :started (if (elt groups 3)
  262. (destructuring-bind (year month day)
  263. (mapcar #'parse-integer
  264. (split-sequence:split-sequence
  265. #\- (elt groups 3)))
  266. (encode-universal-time 0 0 0 day month year))
  267. (get-universal-time)))))
  268. (saver/get-next-time (saver/parse-schedule (saver/payment-schedule payment))
  269. (get-universal-time))
  270. (handler-case
  271. (db/saver/add-payment chat-id payment)
  272. (error (e) (send-response chat-id (format nil "Платёж '~A' уже есть!"
  273. (saver/payment-name payment)))))
  274. (saver/send-info chat-id))
  275. (send-response chat-id "Неправильно. /saver add <title> <amount> <cron> [started]"))))
  276. (defun saver/del-payment (chat-id args)
  277. (handler-case
  278. (let ((payment (elt (db/saver/get-payments chat-id) (1- (parse-integer (car args)))))
  279. (salary (db/saver/get-salary chat-id)))
  280. (db/saver/del-payment chat-id (saver/payment-name payment))
  281. (bot-send-message chat-id
  282. (format nil "'~A' удалил.~@[ Забрать _~$_ из накопленого.~]"
  283. (saver/payment-name payment)
  284. (and salary
  285. (/ (getf (saver/get-payment-info payment salary) :saved-amount)
  286. 100)))
  287. :parse-mode "markdown"))
  288. (error (e) (send-response chat-id (format nil "/saver del <idx> [~A]" e)))))
  289. ;; Hooks
  290. (def-message-cmd-handler handler-cmd-save (:save :saver)
  291. (if (null args)
  292. (saver/send-info chat-id)
  293. (case (keyify (car args))
  294. (:salary (saver/set-salary chat-id (rest args)))
  295. (:add (saver/add-payment chat-id (rest args)))
  296. (:del (saver/del-payment chat-id (rest args)))
  297. (t (send-response chat-id "Чот не понял")))))