ledger.lisp 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.ledger
  3. (:use :cl :chatikbot.common))
  4. (in-package :chatikbot.plugins.ledger)
  5. (eval-when (:compile-toplevel :load-toplevel :execute)
  6. (ql:quickload '(:pta-ledger :legit)))
  7. (defsetting *ledger/default-timezone* -3 "Default timezone for time display. GMT+3")
  8. (defvar *ledger/chat-journals* (make-hash-table))
  9. (defvar *git-repo-locks* (make-hash-table :test #'equal))
  10. (defun git-get-repo-lock (repo)
  11. (let ((key (legit:location repo)))
  12. (setf key
  13. (etypecase key
  14. (string key)
  15. (pathname (namestring key))))
  16. (or (gethash key *git-repo-locks*)
  17. (setf (gethash key *git-repo-locks*)
  18. (bt:make-recursive-lock key)))))
  19. (defun git-get-repo (location remote)
  20. (let ((repo (make-instance 'legit:repository :location location)))
  21. (bt:with-recursive-lock-held ((git-get-repo-lock repo))
  22. (legit:init repo :remote remote :if-does-not-exist :clone))
  23. repo))
  24. (defsetting *git-repos-root* "/tmp/ledger-repos/")
  25. (defun git-get-chat-location (chat-id remote)
  26. (merge-pathnames (format nil "~A-~A" chat-id (token-hmac remote))
  27. *git-repos-root*))
  28. (defun git-read-latest-file (repo path)
  29. (bt:with-recursive-lock-held ((git-get-repo-lock repo))
  30. (legit:fetch repo :branch "master" :remote "origin")
  31. (legit:reset repo :hard t :to "origin/master")
  32. (uiop:read-file-string (merge-pathnames path
  33. (uiop:ensure-directory-pathname
  34. (legit:location repo))))))
  35. (defun ledger/refresh-git (chat-id remote path)
  36. (let* ((location (git-get-chat-location chat-id remote))
  37. (repo (git-get-repo location remote))
  38. (content (git-read-latest-file repo path))
  39. (journal (pta-ledger:parse-journal content))
  40. (updated (legit:current-age repo)))
  41. (setf (gethash chat-id *ledger/chat-journals*)
  42. (cons journal updated))))
  43. (defun git-append-latest-file (repo path text message)
  44. (bt:with-recursive-lock-held ((git-get-repo-lock repo))
  45. (let ((repo-path (merge-pathnames path (legit:location repo))))
  46. (dotimes (tries 5)
  47. (let ((current (or (ignore-errors (git-read-latest-file repo path)) "")))
  48. (uiop/stream:with-output-file (s repo-path
  49. :if-exists :supersede
  50. :if-does-not-exist :create)
  51. (format s "~A~A~%" current text)))
  52. (legit:add repo repo-path)
  53. (legit:commit repo message)
  54. (handler-case
  55. (progn
  56. (legit:push repo)
  57. (return-from git-append-latest-file path))
  58. (legit:git-error ())))
  59. (error "Tried 5 times to push ~A to ~A" path (legit:remote-url repo)))))
  60. (defun ledger/get-hook-url (chat-id)
  61. (get-webhook-url "ledger" chat-id (token-hmac (write-to-string chat-id))))
  62. (defun ledger/format-uri (url)
  63. (let ((uri (quri:uri url)))
  64. (quri:render-uri (quri:make-uri :userinfo (when (quri:uri-userinfo uri) "*:*")
  65. :defaults url))))
  66. (defun ledger/format-time (universal-time)
  67. (when universal-time
  68. (multiple-value-bind (sec min hour day month year dow dst-p tz)
  69. (decode-universal-time universal-time *ledger/default-timezone*)
  70. (declare (ignore dow dst-p tz))
  71. (format nil "~4,'0D-~2,'0D-~2,'0D ~2,'0D:~2,'0D:~2,'0D"
  72. year month day hour min sec))))
  73. (defun ledger/refresh-uri (chat-id uri)
  74. (setf (gethash chat-id *ledger/chat-journals*)
  75. (cons (pta-ledger:parse-journal (http-request uri))
  76. (get-universal-time))))
  77. (defun get-chat-journal-info (chat-id &optional info)
  78. (labels ((process-info (info)
  79. (cond
  80. ((stringp info) (ledger/refresh-uri chat-id info))
  81. ((consp info) (apply #'ledger/refresh-git chat-id info))
  82. (:otherwise nil))))
  83. (let ((journal-info (gethash chat-id *ledger/chat-journals*)))
  84. (if journal-info journal-info
  85. (if info (process-info info)
  86. (with-secret (info (list :ledger chat-id))
  87. (process-info info)))))))
  88. (Defun ledger/handle-info (chat-id)
  89. (with-secret (info (list :ledger chat-id))
  90. (if info
  91. (destructuring-bind (journal . ut)
  92. (get-chat-journal-info chat-id info)
  93. (let ((uri (cond
  94. ((consp info) (car info))
  95. ((stringp info) info))))
  96. (bot-send-message chat-id (format nil "Журнал с ~D записями, из ~A, обновлён ~A.~%Веб-хук: ~A"
  97. (length journal)
  98. (ledger/format-uri uri)
  99. (ledger/format-time ut)
  100. (ledger/get-hook-url chat-id))
  101. :disable-web-preview t)))
  102. (send-response chat-id "Добавь журнал: uri - /ledger <url>; git - /ledger <remote> <path>"))))
  103. (defun ledger/handle-set-info (chat-id info)
  104. (setf (gethash chat-id *ledger/chat-journals*) nil)
  105. (handler-case
  106. (destructuring-bind (journal . ut)
  107. (get-chat-journal-info chat-id info)
  108. (declare (ignore ut))
  109. (secret-set (list :ledger chat-id) info)
  110. (send-response chat-id (format nil "Добавил журнал с ~D записями. Веб-хук для обновления: ~A"
  111. (length journal)
  112. (ledger/get-hook-url chat-id))))
  113. (pta-ledger:journal-failed (e)
  114. (send-response chat-id (format nil "Не смог спарсить: ~A" e)))
  115. (dex:http-request-failed (e)
  116. (send-response chat-id (format nil "Не смог в урл: ~A" (dex:response-body e))))))
  117. (def-message-cmd-handler handler-ledger (:ledger)
  118. (cond
  119. ((<= 1 (length args) 2) (ledger/handle-set-info chat-id args))
  120. (:otherwise (ledger/handle-info chat-id))))
  121. (defmacro with-chat-journal ((chat-id journal updated) &body body)
  122. (let ((info (gensym "info")))
  123. `(let ((,info (get-chat-journal-info ,chat-id)))
  124. (if ,info
  125. (destructuring-bind (,journal . ,updated) ,info
  126. ,@body)
  127. (send-response ,chat-id "Добавь журнал: uri - /ledger <url>; git - /ledger <remote> <path>")))))
  128. (defun ledger/handle-balance (chat-id query)
  129. (with-chat-journal (chat-id journal updated)
  130. (bot-send-message chat-id (format nil "```~%~A~%```Обновлено: ~A"
  131. (pta-ledger:journal-balance journal query)
  132. (ledger/format-time updated))
  133. :parse-mode "markdown")))
  134. (def-message-cmd-handler handler-balance (:balance :bal)
  135. (cond
  136. ((null args) (ledger/handle-balance chat-id "assets"))
  137. (:otherwise (ledger/handle-balance chat-id (spaced args)))))
  138. (defun ledger/handle-journal (chat-id query)
  139. (with-chat-journal (chat-id journal updated)
  140. (let* ((entries (pta-ledger:journal-print journal query))
  141. (len (length entries))
  142. (count (min len 20)))
  143. (bot-send-message chat-id (format nil "```~%~{~A~^ ~%~%~}```Обновлено: ~A"
  144. (subseq entries (- len count) len)
  145. (ledger/format-time updated))
  146. :parse-mode "markdown"))))
  147. (def-message-cmd-handler handler-journal (:journal)
  148. (cond
  149. ((null args) (ledger/handle-journal chat-id "date:thisweek"))
  150. (:otherwise (ledger/handle-journal chat-id (spaced args)))))
  151. (def-webhook-handler ledger/handle-webhook ("ledger")
  152. (when (= 2 (length paths))
  153. (destructuring-bind (chat-id hmac) paths
  154. (let ((true-hmac (token-hmac chat-id)))
  155. (when (string= true-hmac hmac)
  156. (with-secret (info (list :ledger chat-id))
  157. (when info
  158. (let ((chat-id (parse-integer chat-id)))
  159. (setf (gethash chat-id *ledger/chat-journals*) nil)
  160. (get-chat-journal-info chat-id info)
  161. "OK"))))))))