| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- (in-package #:chatikbot)
- (defsetting *saver-default-timezone* 3 "Default timezone for *saver-notify-hour* calculation. GMT+3")
- (defsetting *saver-notify-hour* 11 "Notify with upcoming payments and saves at this time")
- (defstruct saver/payment name amount schedule started)
- (defun saver/parse-schedule (schedule)
- (labels ((parse (text &optional def-min def-max)
- (let ((dash-pos (position #\- text))
- (star-pos (position #\* text))
- (slash-pos (position #\/ text)))
- (if (or dash-pos star-pos slash-pos)
- (let ((min (if star-pos def-min
- (when dash-pos (parse-integer text :end dash-pos))))
- (max (if star-pos def-max
- (when dash-pos (parse-integer text :start (1+ dash-pos) :end slash-pos))))
- (step (or (when slash-pos (parse-integer text :start (1+ slash-pos))) 1)))
- (list nil min max step))
- (list (sort (mapcar #'parse-integer (split-sequence:split-sequence #\, text)) #'<))))))
- (destructuring-bind (&optional day-sched month-sched year-sched)
- (split-sequence:split-sequence #\Space schedule :remove-empty-subseqs t)
- (list (if day-sched (parse day-sched 1 31) (list nil 1 31))
- (if month-sched (parse month-sched 1 12) (list nil 1 12))
- (if year-sched (parse year-sched 1900) (list nil 1900))))))
- (defun saver/get-next-time (schedule universal-time &optional (dir t))
- (labels ((leapp (year)
- (or (and (zerop (mod year 4))
- (not (zerop (mod year 100))))
- (zerop (mod year 400))))
- (clamp-day-rule (day-rule month year)
- (destructuring-bind (lst &optional (min 0) max (step 1)) day-rule
- (let ((max-day (case month
- (2 (if (leapp year) 29 28))
- ((1 3 5 7 8 10 12) 31)
- (otherwise 30))))
- (if (consp lst)
- (list (remove-duplicates (mapcar (lambda (d) (min d max-day)) lst)))
- (list nil (min min) (when max (min max max-day)) step)))))
- (next (from rule)
- (destructuring-bind (lst &optional (min 0) max (step 1)) rule
- (if (consp lst)
- (let ((nv (find from lst :test (if dir #'<= #'>=) :from-end (not dir))))
- (if nv
- (values nv nil)
- (values (car (if dir lst (last lst))) t)))
- (let* ((m (mod (- from min) step))
- (nv (if (zerop m) from
- (funcall (if dir #'+ #'-) from
- (if dir (- step m) m)))))
- (cond
- ((and max (> nv max))
- (if dir
- (values min t)
- (values max nil)))
- ((and min (< nv min))
- (if dir
- (values min nil)
- (values max t)))
- (t (values nv nil)))))))
- (add (v of)
- (funcall (if dir #'+ #'-) v (if of 1 0)))
- (reset-rule (rule)
- (destructuring-bind (lst &optional (min 0) max (step 1)) rule
- (if (consp lst)
- (car (if dir lst (last lst)))
- (if dir min
- (let ((m (mod (- max min) step)))
- (- max (if (zerop m) 0 m))))))))
- (multiple-value-bind (second minute hour day month year dow dst-p tz) (decode-universal-time universal-time)
- (declare (ignore second minute hour dow dst-p tz))
- (destructuring-bind (&optional (day-rule '(nil 1 31)) (month-rule '(nil 1 12)) (year-rule '(nil 1900)))
- schedule
- (multiple-value-bind (next-day of-day) (next (add day (not dir))
- (clamp-day-rule day-rule month year))
- (multiple-value-bind (next-month of-month) (next (add month of-day) month-rule)
- (multiple-value-bind (next-year of-year) (next (add year of-month) year-rule)
- (unless of-year
- (let ((next-month (if (= next-year year) next-month (reset-rule month-rule))))
- (encode-universal-time
- 0 0 0
- (if (and (= next-month month) (= next-year year)) next-day
- (reset-rule (clamp-day-rule day-rule next-month next-year)))
- next-month
- next-year))))))))))
- (defun saver/count-events (from to schedule)
- (loop for ut = (saver/get-next-time schedule from) then (saver/get-next-time
- schedule (+ ut 86400))
- while (< ut to)
- counting ut))
- (defun saver/get-payment-info (payment salary &optional (moment (get-universal-time)))
- (let* ((salary (saver/parse-schedule salary))
- (schedule (saver/parse-schedule (saver/payment-schedule payment)))
- (next-payment (saver/get-next-time schedule moment))
- (prev-payment (max (saver/get-next-time schedule moment nil)
- (or (saver/payment-started payment) 0))))
- (when next-payment
- (let* ((total-periods (saver/count-events prev-payment next-payment salary))
- (saved-periods (saver/count-events prev-payment moment salary))
- (period-amount (floor (saver/payment-amount payment) (max 1 total-periods)))
- (saved-amount (* saved-periods period-amount)))
- (list :next-payment next-payment
- :prev-payment prev-payment
- :total-periods total-periods
- :period-amount period-amount
- :saved-periods saved-periods
- :saved-amount saved-amount
- :left-periods (- total-periods saved-periods)
- :left-amount (- (saver/payment-amount payment) saved-amount))))))
- (defun saver/get-period-info (salary payments &optional (moment (get-universal-time)))
- (let ((next-salary (saver/get-next-time (saver/parse-schedule salary) moment)))
- (when next-salary
- (loop for payment in payments
- for cur-info = (saver/get-payment-info payment salary moment)
- for next-info = (saver/get-payment-info payment salary next-salary)
- summing (getf cur-info :saved-amount) into total-saved
- summing (getf cur-info :left-amount) into total-left
- summing (if (> (getf next-info :left-periods) 1)
- (getf next-info :period-amount)
- (getf next-info :left-amount)) into total-period
- finally (return
- (list :next-salary next-salary
- :total-saved total-saved
- :total-left total-left
- :total-period total-period))))))
- (defun %saver/format-time (universal-time)
- (when universal-time
- (multiple-value-bind (sec min hour day month year dow dst-p tz)
- (decode-universal-time universal-time)
- (declare (ignore sec min hour dow dst-p tz))
- (format nil "~4,'0D-~2,'0D-~2,'0D" year month day))))
- ;; Database
- (def-db-init
- (db-execute "create table if not exists saver_payments (chat_id, name, amount, schedule, started, notified)")
- (db-execute "create unique index if not exists saver_payments_chat_id_name_idx on saver_payments (chat_id, name)")
- (db-execute "create table if not exists saver_salaries (chat_id, schedule, notified)")
- (db-execute "create unique index if not exists saver_salaries_chat_id_idx on saver_salaries (chat_id)"))
- (defun %db/saver/make-payment (row)
- (when row
- (make-saver/payment :name (nth 0 row)
- :amount (nth 1 row)
- :schedule (nth 2 row)
- :started (nth 3 row))))
- (defun db/saver/get-payments (chat-id)
- (mapcar #'%db/saver/make-payment
- (db-select "select name, amount, schedule, started from saver_payments where chat_id = ? order by started" chat-id)))
- (defun db/saver/get-not-notified-payments (notified)
- (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))
- collect (cons (nth 4 row) (%db/saver/make-payment row))))
- (defun db/saver/set-payment-notified (chat-id name moment)
- (db-execute "update saver_payments set notified = ? where chat_id = ? and name = ?"
- (%saver/format-time moment) chat-id name))
- (defun db/saver/add-payment (chat-id payment)
- (with-slots (name amount schedule started) payment
- (db-execute "insert into saver_payments (chat_id, name, amount, schedule, started) values (?, ?, ?, ?, ?)"
- chat-id name amount schedule started)))
- (defun db/saver/del-payment (chat-id name)
- (db-execute "delete from saver_payments where chat_id = ? and name = ?" chat-id name))
- (defun db/saver/get-salary (chat-id)
- (caar (db-select "select schedule from saver_salaries where chat_id = ?" chat-id)))
- (defun db/saver/get-not-notified-salaries (notified)
- (loop for row in (db-select "select chat_id, schedule from saver_salaries where notified is null or notified != ?" (%saver/format-time notified))
- collect (cons (nth 0 row) (nth 1 row))))
- (defun db/saver/set-salary-notified (chat-id moment)
- (db-execute "update saver_salaries set notified = ? where chat_id = ?"
- (%saver/format-time moment) chat-id))
- (defun db/saver/set-salary (chat-id schedule)
- (db-transaction
- (db-execute "delete from saver_salaries where chat_id = ?" chat-id)
- (db-execute "insert into saver_salaries (chat_id, schedule) values (?, ?)" chat-id schedule)))
- ;; Cron
- (defun saver/find-today-payments (&optional (moment (get-universal-time)))
- (remove-if-not (lambda (p) (string= (%saver/format-time moment)
- (%saver/format-time
- (saver/get-next-time
- (saver/parse-schedule (saver/payment-schedule p))
- moment))))
- (db/saver/get-not-notified-payments moment)
- :key #'cdr))
- (defun %saver/format-payment-notification (payment moment)
- (format nil "'~A' надо оплатить *~A* на сумму _~$_"
- (saver/payment-name payment)
- (%saver/format-time moment)
- (/ (saver/payment-amount payment) 100)))
- (defun saver/find-today-salaries (&optional (moment (get-universal-time)))
- (remove-if-not (lambda (p) (string= (%saver/format-time moment)
- (%saver/format-time (saver/get-next-time
- (saver/parse-schedule p) moment))))
- (db/saver/get-not-notified-salaries moment)
- :key #'cdr))
- (defun %saver/format-salary-notification (salary payments moment)
- (let ((period-info (saver/get-period-info salary payments moment)))
- (format nil "*~A* зарплата. Отложи _~$_!"
- (%saver/format-time (getf period-info :next-salary))
- (/ (getf period-info :total-period) 100))))
- (defcron process-saver (:hour '*)
- (let* ((moment (get-universal-time))
- (hour (nth 2 (multiple-value-list (decode-universal-time moment *saver-default-timezone*)))))
- (when (>= hour *saver-notify-hour*)
- (dolist (cp (saver/find-today-payments moment))
- (db-transaction
- (bot-send-message (car cp) (%saver/format-payment-notification (cdr cp) moment) :parse-mode "markdown")
- (db/saver/set-payment-notified (car cp) (saver/payment-name (cdr cp)) moment)))
- (dolist (cs (saver/find-today-salaries moment))
- (db-transaction
- (bot-send-message (car cs)
- (%saver/format-salary-notification (cdr cs)
- (db/saver/get-payments (car cs))
- moment)
- :parse-mode "markdown")
- (db/saver/set-salary-notified (car cs) moment))))))
- ;; Bot subcommands
- (defun %saver/format-info (payments salary &optional (moment (get-universal-time)))
- (let* (closest-time
- closest-payment
- (payments-info
- (loop for payment in payments
- for idx from 1
- for info = (saver/get-payment-info payment salary moment)
- when info
- when (or (null closest-time)
- (< (getf info :next-payment) closest-time))
- do (setf closest-time (getf info :next-payment)
- closest-payment payment)
- collect (format nil "~D) ~A по [[~A]]: накоплено _~$_ из _~$_, осталось _~$_ за ~A платежа"
- idx
- (saver/payment-name payment)
- (saver/payment-schedule payment)
- (/ (getf info :saved-amount) 100)
- (/ (saver/payment-amount payment) 100)
- (/ (getf info :left-amount) 100)
- (getf info :left-periods))))
- (period-info (saver/get-period-info salary payments moment)))
- (if payments-info
- (format nil "*Платежи*~%~{~A~^~%~}~%~%Накоплено должно быть _~$_~%Следующее накопление *~A* на _~$_~%Следующий платёж '~A' *~A*, _~$_"
- payments-info
- (/ (getf period-info :total-saved) 100)
- (%saver/format-time (getf period-info :next-salary))
- (/ (getf period-info :total-period) 100)
- (saver/payment-name closest-payment)
- (%saver/format-time closest-time)
- (/ (saver/payment-amount closest-payment) 100))
- "Нет активных платежей.")))
- (defun saver/send-info (chat-id)
- (let ((payments (db/saver/get-payments chat-id))
- (salary (db/saver/get-salary chat-id)))
- (if salary
- (if payments
- (bot-send-message chat-id (%saver/format-info payments salary) :parse-mode "markdown")
- (bot-send-message chat-id (format nil "Платежей нет, зарплата: ~A" salary)))
- (send-response chat-id "Зарплата не задана, /saver salary <cron>"))))
- (defun saver/set-salary (chat-id args)
- (let ((sched (spaced args)))
- (saver/parse-schedule sched)
- (db/saver/set-salary chat-id sched)
- (send-response chat-id (format nil "Зарплата теперь '~A'" sched))))
- (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}))?$"))
- (defun saver/add-payment (chat-id args)
- (multiple-value-bind (matched groups) (cl-ppcre:scan-to-strings +saver/add-scanner+ (spaced args))
- (if matched
- (let ((payment (make-saver/payment :name (elt groups 0)
- :amount (round (* 100 (read-from-string (elt groups 1))))
- :schedule (elt groups 2)
- :started (if (elt groups 3)
- (destructuring-bind (year month day)
- (mapcar #'parse-integer
- (split-sequence:split-sequence
- #\- (elt groups 3)))
- (encode-universal-time 0 0 0 day month year))
- (get-universal-time)))))
- (saver/get-next-time (saver/parse-schedule (saver/payment-schedule payment))
- (get-universal-time))
- (handler-case
- (db/saver/add-payment chat-id payment)
- (error (e) (send-response chat-id (format nil "Платёж '~A' уже есть!"
- (saver/payment-name payment)))))
- (saver/send-info chat-id))
- (send-response chat-id "Неправильно. /saver add <title> <amount> <cron> [started]"))))
- (defun saver/del-payment (chat-id args)
- (handler-case
- (let ((payment (elt (db/saver/get-payments chat-id) (1- (parse-integer (car args)))))
- (salary (db/saver/get-salary chat-id)))
- (db/saver/del-payment chat-id (saver/payment-name payment))
- (bot-send-message chat-id
- (format nil "'~A' удалил.~@[ Забрать _~$_ из накопленого.~]"
- (saver/payment-name payment)
- (and salary
- (/ (getf (saver/get-payment-info payment salary) :saved-amount)
- 100)))
- :parse-mode "markdown"))
- (error (e) (send-response chat-id (format nil "/saver del <idx> [~A]" e)))))
- ;; Hooks
- (def-message-cmd-handler handler-cmd-save (:save :saver)
- (if (null args)
- (saver/send-info chat-id)
- (case (keyify (car args))
- (:salary (saver/set-salary chat-id (rest args)))
- (:add (saver/add-payment chat-id (rest args)))
- (:del (saver/del-payment chat-id (rest args)))
- (t (send-response chat-id "Чот не понял")))))
|