| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- (in-package :cl-user)
- (defpackage chatikbot.plugins.ledger
- (:use :cl :chatikbot.common))
- (in-package :chatikbot.plugins.ledger)
- (eval-when (:compile-toplevel :load-toplevel :execute)
- (ql:quickload '(:pta-ledger :legit)))
- (defsetting *ledger/default-timezone* -3 "Default timezone for time display. GMT+3")
- (defvar *ledger/chat-journals* (make-hash-table))
- (defvar *git-repo-locks* (make-hash-table :test #'equal))
- (defun git-get-repo-lock (repo)
- (let ((key (legit:location repo)))
- (setf key
- (etypecase key
- (string key)
- (pathname (namestring key))))
- (or (gethash key *git-repo-locks*)
- (setf (gethash key *git-repo-locks*)
- (bt:make-recursive-lock key)))))
- (defun git-get-repo (location remote)
- (let ((repo (make-instance 'legit:repository :location location)))
- (bt:with-recursive-lock-held ((git-get-repo-lock repo))
- (legit:init repo :remote remote :if-does-not-exist :clone))
- repo))
- (defsetting *git-repos-root* "/tmp/ledger-repos/")
- (defun git-get-chat-location (chat-id remote)
- (namestring (uiop:ensure-directory-pathname
- (merge-pathnames (format nil "~A-~A" chat-id (token-hmac remote))
- *git-repos-root*))))
- (defun git-read-latest-file (repo path)
- (bt:with-recursive-lock-held ((git-get-repo-lock repo))
- (legit:fetch repo :branch "master" :remote "origin")
- (legit:reset repo :hard t :to "origin/master")
- (uiop:read-file-string (merge-pathnames path (legit:location repo)))))
- (defun ledger/refresh-git (chat-id remote path)
- (let* ((location (git-get-chat-location chat-id remote))
- (repo (git-get-repo location remote))
- (content (git-read-latest-file repo path))
- (journal (pta-ledger:parse-journal content))
- (updated (legit:current-age repo)))
- (setf (gethash chat-id *ledger/chat-journals*)
- (cons journal updated))))
- (defun git-append-latest-file (repo path text message)
- (bt:with-recursive-lock-held ((git-get-repo-lock repo))
- (let ((repo-path (merge-pathnames path (legit:location repo))))
- (dotimes (tries 5)
- (let ((current (or (ignore-errors (git-read-latest-file repo path)) "")))
- (uiop/stream:with-output-file (s repo-path
- :if-exists :supersede
- :if-does-not-exist :create)
- (format s "~A~A" current text)))
- (legit:add repo (namestring repo-path))
- (legit:commit repo message)
- (handler-case
- (progn
- (legit:push repo)
- (return-from git-append-latest-file path))
- (legit:git-error ())))
- (error "Tried 5 times to push ~A to ~A" path (legit:remote-url repo)))))
- (defun ledger/add-git (chat-id remote path text message)
- (let* ((location (git-get-chat-location chat-id remote))
- (repo (git-get-repo location remote)))
- (git-append-latest-file repo path
- (format nil "~%~A~%" text)
- message)))
- (defun ledger/get-hook-url (chat-id)
- (get-webhook-url "ledger" chat-id (token-hmac (write-to-string chat-id))))
- (defun ledger/format-uri (url)
- (let ((uri (quri:uri url)))
- (quri:render-uri (quri:make-uri :userinfo (when (quri:uri-userinfo uri) "*:*")
- :defaults url))))
- (defun ledger/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 *ledger/default-timezone*)
- (declare (ignore dow dst-p tz))
- (format nil "~4,'0D-~2,'0D-~2,'0D ~2,'0D:~2,'0D:~2,'0D"
- year month day hour min sec))))
- (defun ledger/refresh-uri (chat-id uri)
- (setf (gethash chat-id *ledger/chat-journals*)
- (cons (pta-ledger:parse-journal (http-request uri))
- (get-universal-time))))
- (defun get-chat-journal-info (chat-id &optional info)
- (labels ((process-info (info)
- (cond
- ((stringp info) (ledger/refresh-uri chat-id info))
- ((consp info) (apply #'ledger/refresh-git chat-id info))
- (:otherwise nil))))
- (let ((journal-info (gethash chat-id *ledger/chat-journals*)))
- (if journal-info journal-info
- (if info (process-info info)
- (with-secret (info (list :ledger chat-id))
- (process-info info)))))))
- (Defun ledger/handle-info (chat-id)
- (with-secret (info (list :ledger chat-id))
- (if info
- (destructuring-bind (journal . ut)
- (get-chat-journal-info chat-id info)
- (let ((uri (cond
- ((consp info) (car info))
- ((stringp info) info))))
- (bot-send-message chat-id (format nil "Журнал с ~D записями, из ~A, обновлён ~A.~%Веб-хук: ~A"
- (length journal)
- (ledger/format-uri uri)
- (ledger/format-time ut)
- (ledger/get-hook-url chat-id))
- :disable-web-preview t)))
- (send-response chat-id "Добавь журнал: uri - /ledger <url>; git - /ledger <remote> <path>"))))
- (defun ledger/handle-set-info (chat-id info)
- (setf (gethash chat-id *ledger/chat-journals*) nil)
- (handler-case
- (destructuring-bind (journal . ut)
- (get-chat-journal-info chat-id info)
- (declare (ignore ut))
- (secret-set (list :ledger chat-id) info)
- (send-response chat-id (format nil "Добавил журнал с ~D записями. Веб-хук для обновления: ~A"
- (length journal)
- (ledger/get-hook-url chat-id))))
- (pta-ledger:journal-failed (e)
- (send-response chat-id (format nil "Не смог спарсить: ~A" e)))
- (dex:http-request-failed (e)
- (send-response chat-id (format nil "Не смог в урл: ~A" (dex:response-body e))))))
- (def-message-cmd-handler handler-ledger (:ledger)
- (cond
- ((<= 1 (length args) 2) (ledger/handle-set-info chat-id args))
- (:otherwise (ledger/handle-info chat-id))))
- (defmacro with-chat-journal ((chat-id journal updated) &body body)
- (let ((info (gensym "info")))
- `(let ((,info (get-chat-journal-info ,chat-id)))
- (if ,info
- (destructuring-bind (,journal . ,updated) ,info
- ,@body)
- (send-response ,chat-id "Добавь журнал: uri - /ledger <url>; git - /ledger <remote> <path>")))))
- (defun ledger/handle-balance (chat-id query)
- (with-chat-journal (chat-id journal updated)
- (bot-send-message chat-id (format nil "```~%~A~%```Обновлено: ~A"
- (pta-ledger:journal-balance journal query)
- (ledger/format-time updated))
- :parse-mode "markdown")))
- (def-message-cmd-handler handler-balance (:balance :bal)
- (cond
- ((null args) (ledger/handle-balance chat-id "assets"))
- (:otherwise (ledger/handle-balance chat-id (spaced args)))))
- (defun ledger/handle-journal (chat-id query)
- (with-chat-journal (chat-id journal updated)
- (let* ((entries (pta-ledger:journal-print journal query))
- (len (length entries))
- (count (min len 20)))
- (bot-send-message chat-id (format nil "```~%~{~A~^ ~%~%~}```Обновлено: ~A"
- (subseq entries (- len count) len)
- (ledger/format-time updated))
- :parse-mode "markdown"))))
- (def-message-cmd-handler handler-journal (:journal)
- (cond
- ((null args) (ledger/handle-journal chat-id "date:thisweek"))
- (:otherwise (ledger/handle-journal chat-id (spaced args)))))
- (def-webhook-handler ledger/handle-webhook ("ledger")
- (when (= 2 (length paths))
- (destructuring-bind (chat-id hmac) paths
- (let ((true-hmac (token-hmac chat-id)))
- (when (string= true-hmac hmac)
- (with-secret (info (list :ledger chat-id))
- (when info
- (let ((chat-id (parse-integer chat-id)))
- (log:info "Updating ledger for ~A" chat-id)
- (setf (gethash chat-id *ledger/chat-journals*) nil)
- (get-chat-journal-info chat-id info)
- "OK"))))))))
- ;; New entries
- (defun format-entry (entry)
- (format nil "```~%~A```" (pta-ledger:render entry)))
- (defparameter +new-entry-actions+
- `(("a" . "➕")
- ("e" . "💲")
- ("c" . "✖️")))
- (defun ledger/new-chat-entry (chat-id entry)
- (bot-send-message chat-id (format-entry entry)
- :parse-mode "markdown"
- :reply-markup (telegram-inline-keyboard-markup
- (list (loop for (a . l) in +new-entry-actions+
- collect (list :text l
- :callback-data
- (encode-callback-data
- chat-id :ln a 86400)))))))
- (defun ledger/format-add-entry-message (from)
- (format nil "Ledger add from ~A ~A at ~A"
- (aget "first_name" from) (aget "last_name" from)
- (ledger/format-time (get-universal-time))))
- (defun ledger/process-add-entry (chat-id callback)
- (with-secret (info (list :ledger chat-id))
- (if info
- (cond
- ((consp info)
- (handler-case (progn
- (ledger/add-git
- chat-id
- (car info) (cadr info)
- (agets callback "message" "text")
- (ledger/format-add-entry-message (agets callback "from")))
- "Добавил!")
- (error (e)
- (log:error "~A" e)
- "Не смог :(")))
- (:otherwise "Добавляю только в git журнал :("))
- "Добавь git журнал.")))
- (def-callback-section-handler cb-handle-ln (:ln)
- (case (keyify data)
- (:a (telegram-answer-callback-query query-id :text "Добавляю...")
- (telegram-send-message chat-id (ledger/process-add-entry
- chat-id callback))
- (telegram-edit-message-reply-markup nil :chat-id chat-id :message-id message-id))
- (:e (telegram-answer-callback-query query-id :text "TBD"))
- (:c (telegram-delete-message chat-id message-id))))
|