ledger.lisp 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  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. (namestring (uiop:ensure-directory-pathname
  27. (merge-pathnames (format nil "~A-~A" chat-id (token-hmac remote))
  28. *git-repos-root*))))
  29. (defun git-read-latest-file (repo path)
  30. (bt:with-recursive-lock-held ((git-get-repo-lock repo))
  31. (legit:fetch repo :branch "master" :remote "origin")
  32. (legit:reset repo :hard t :to "origin/master")
  33. (uiop:read-file-string (merge-pathnames path (legit:location repo)))))
  34. (defun ledger/refresh-git (chat-id remote path)
  35. (let* ((location (git-get-chat-location chat-id remote))
  36. (repo (git-get-repo location remote))
  37. (content (git-read-latest-file repo path))
  38. (journal (pta-ledger:parse-journal content))
  39. (updated (legit:current-age repo)))
  40. (setf (gethash chat-id *ledger/chat-journals*)
  41. (cons journal updated))))
  42. (defun git-append-latest-file (repo path text message)
  43. (bt:with-recursive-lock-held ((git-get-repo-lock repo))
  44. (let ((repo-path (merge-pathnames path (legit:location repo))))
  45. (dotimes (tries 5)
  46. (let ((current (or (ignore-errors (git-read-latest-file repo path)) "")))
  47. (uiop/stream:with-output-file (s repo-path
  48. :if-exists :supersede
  49. :if-does-not-exist :create)
  50. (format s "~A~A" current text)))
  51. (legit:add repo (namestring repo-path))
  52. (legit:commit repo message)
  53. (handler-case
  54. (progn
  55. (legit:push repo)
  56. (return-from git-append-latest-file path))
  57. (legit:git-error ())))
  58. (error "Tried 5 times to push ~A to ~A" path (legit:remote-url repo)))))
  59. (defun ledger/add-git (chat-id remote path text message)
  60. (let* ((location (git-get-chat-location chat-id remote))
  61. (repo (git-get-repo location remote)))
  62. (git-append-latest-file repo path
  63. (format nil "~%~A~%" text)
  64. message)))
  65. (defun ledger/get-hook-url (chat-id)
  66. (get-webhook-url "ledger" chat-id (token-hmac (write-to-string chat-id))))
  67. (defun ledger/format-uri (url)
  68. (let ((uri (quri:uri url)))
  69. (quri:render-uri (quri:make-uri :userinfo (when (quri:uri-userinfo uri) "*:*")
  70. :defaults url))))
  71. (defun ledger/format-time (universal-time)
  72. (when universal-time
  73. (multiple-value-bind (sec min hour day month year dow dst-p tz)
  74. (decode-universal-time universal-time *ledger/default-timezone*)
  75. (declare (ignore dow dst-p tz))
  76. (format nil "~4,'0D-~2,'0D-~2,'0D ~2,'0D:~2,'0D:~2,'0D"
  77. year month day hour min sec))))
  78. (defun format-date (ut)
  79. (multiple-value-bind (sec min hour day month year)
  80. (decode-universal-time ut)
  81. (declare (ignore sec min hour))
  82. (format nil "~4,'0D-~2,'0D-~2,'0D"
  83. year month day)))
  84. (defun get-year (ut)
  85. (multiple-value-bind (sec min hour day month year)
  86. (decode-universal-time ut)
  87. (declare (ignore sec min hour day month))
  88. year))
  89. (defun ledger/refresh-uri (chat-id uri)
  90. (setf (gethash chat-id *ledger/chat-journals*)
  91. (cons (pta-ledger:parse-journal (http-request uri))
  92. (get-universal-time))))
  93. (defun get-chat-journal-info (chat-id &optional info)
  94. (labels ((process-info (info)
  95. (cond
  96. ((stringp info) (ledger/refresh-uri chat-id info))
  97. ((consp info) (apply #'ledger/refresh-git chat-id info))
  98. (:otherwise nil))))
  99. (let ((journal-info (gethash chat-id *ledger/chat-journals*)))
  100. (if journal-info journal-info
  101. (if info (process-info info)
  102. (with-secret (info (list :ledger chat-id))
  103. (process-info info)))))))
  104. (Defun ledger/handle-info (chat-id)
  105. (with-secret (info (list :ledger chat-id))
  106. (if info
  107. (destructuring-bind (journal . ut)
  108. (get-chat-journal-info chat-id info)
  109. (let ((uri (cond
  110. ((consp info) (car info))
  111. ((stringp info) info))))
  112. (bot-send-message chat-id (format nil "Журнал с ~D записями, из ~A, обновлён ~A.~%Веб-хук: ~A"
  113. (length journal)
  114. (ledger/format-uri uri)
  115. (ledger/format-time ut)
  116. (ledger/get-hook-url chat-id))
  117. :disable-web-preview t)))
  118. (bot-send-message chat-id "Добавь журнал: uri - /ledger <url>; git - /ledger <remote> <path>"))))
  119. (defun ledger/handle-set-info (chat-id info)
  120. (setf (gethash chat-id *ledger/chat-journals*) nil)
  121. (handler-case
  122. (destructuring-bind (journal . ut)
  123. (get-chat-journal-info chat-id info)
  124. (declare (ignore ut))
  125. (secret-set (list :ledger chat-id) info)
  126. (bot-send-message chat-id (format nil "Добавил журнал с ~D записями. Веб-хук для обновления: ~A"
  127. (length journal)
  128. (ledger/get-hook-url chat-id))))
  129. (pta-ledger:journal-failed (e)
  130. (bot-send-message chat-id (format nil "Не смог спарсить: ~A" e)))
  131. (dex:http-request-failed (e)
  132. (bot-send-message chat-id (format nil "Не смог в урл: ~A" (dex:response-body e))))))
  133. (def-message-cmd-handler handler-ledger (:ledger)
  134. (cond
  135. ((<= 1 (length args) 2) (ledger/handle-set-info chat-id args))
  136. (:otherwise (ledger/handle-info chat-id))))
  137. (defmacro with-chat-journal ((chat-id journal updated) &body body)
  138. (let ((info (gensym "info")))
  139. `(let ((,info (get-chat-journal-info ,chat-id)))
  140. (if ,info
  141. (destructuring-bind (,journal . ,updated) ,info
  142. (declare (ignorable ,journal ,updated))
  143. ,@body)
  144. (bot-send-message ,chat-id "Добавь журнал: uri - /ledger <url>; git - /ledger <remote> <path>")))))
  145. (defun ledger/handle-balance (chat-id query)
  146. (with-chat-journal (chat-id journal updated)
  147. (bot-send-message chat-id (format nil "```~%~A~%```Обновлено: ~A"
  148. (pta-ledger:journal-balance journal query)
  149. (ledger/format-time updated))
  150. :parse-mode "markdown")))
  151. (def-message-cmd-handler handler-balance (:balance :bal)
  152. (cond
  153. ((null args) (ledger/handle-balance chat-id "assets"))
  154. (:otherwise (ledger/handle-balance chat-id (spaced args)))))
  155. (defun ledger/handle-journal (chat-id query)
  156. (with-chat-journal (chat-id journal updated)
  157. (let* ((pta-ledger:*posting-length* 40)
  158. (entries (pta-ledger:journal-print journal query))
  159. (len (length entries))
  160. (count (min len 20)))
  161. (bot-send-message chat-id (format nil "```~%~{~A~^ ~%~%~}```Обновлено: ~A"
  162. (subseq entries (- len count) len)
  163. (ledger/format-time updated))
  164. :parse-mode "markdown"))))
  165. (def-message-cmd-handler handler-journal (:journal)
  166. (cond
  167. ((null args) (ledger/handle-journal chat-id "date:thisweek"))
  168. (:otherwise (ledger/handle-journal chat-id (spaced args)))))
  169. (defun match-entry (text journal)
  170. (labels ((two-post (entries)
  171. (remove-if-not #'(lambda (e)
  172. (= 2 (length (pta-ledger:entry-postings e))))
  173. entries)))
  174. (let ((desc (two-post
  175. (pta-ledger:journal-entries journal (format nil "desc:'~A'" text)))))
  176. (if desc (car (last desc))
  177. (let ((comment (two-post
  178. (pta-ledger:journal-entries journal (format nil "comment:'~A'" text)))))
  179. (when comment (car (last comment))))))))
  180. (defun create-entry (chat-id text amount currency)
  181. (with-chat-journal (chat-id journal updated)
  182. (let* ((old (match-entry text journal))
  183. (new (or (when old (pta-ledger:clone-entry old))
  184. (pta-ledger:make-entry
  185. :description text
  186. :postings (list
  187. (pta-ledger:make-posting :account "expenses")
  188. (pta-ledger:make-posting)))))
  189. (postings (pta-ledger:entry-postings new)))
  190. (setf (pta-ledger:entry-date new) (get-universal-time)
  191. (pta-ledger:posting-amount (car postings)) (pta-ledger:make-amount
  192. :quantity amount
  193. :commodity currency)
  194. (pta-ledger:posting-amount (cadr postings)) (pta-ledger:make-amount
  195. :quantity (- amount)
  196. :commodity currency)
  197. (pta-ledger:posting-account (cadr postings)) (format nil "assets:Cash:~A" currency))
  198. new)))
  199. (def-message-cmd-handler handler-create (:rub :usd :eur :thb :btc)
  200. (cond
  201. ((>= (length args) 2)
  202. (ledger/new-chat-entry chat-id (create-entry chat-id
  203. (spaced (subseq args 1))
  204. (parse-float (car args))
  205. (symbol-name cmd))))
  206. (:otherwise (bot-send-message chat-id (format nil "/~A <amount> <description>" cmd)))))
  207. (def-webhook-handler ledger/handle-webhook ("ledger")
  208. (when (= 2 (length paths))
  209. (destructuring-bind (chat-id hmac) paths
  210. (let ((true-hmac (token-hmac chat-id)))
  211. (when (string= true-hmac hmac)
  212. (with-secret (info (list :ledger chat-id))
  213. (when info
  214. (let ((chat-id (parse-integer chat-id)))
  215. (log:info "Updating ledger for ~A" chat-id)
  216. (setf (gethash chat-id *ledger/chat-journals*) nil)
  217. (get-chat-journal-info chat-id info)
  218. "OK"))))))))
  219. ;; New entries
  220. (defun format-entry (entry)
  221. (let ((pta-ledger:*posting-length* 40))
  222. (format nil "```~%~A```" (pta-ledger:render entry))))
  223. (defun ledger/format-add-entry-message (from)
  224. (format nil "Ledger add from ~A ~A at ~A"
  225. (aget "first_name" from) (aget "last_name" from)
  226. (ledger/format-time (get-universal-time))))
  227. (defun ledger/process-add-entry (chat-id from entry)
  228. (with-secret (info (list :ledger chat-id))
  229. (if info
  230. (cond
  231. ((consp info)
  232. (handler-case (progn
  233. (ledger/add-git
  234. chat-id
  235. (car info) (cadr info)
  236. (pta-ledger:render entry)
  237. (ledger/format-add-entry-message from))
  238. "Добавил!")
  239. (error (e)
  240. (log:error "~A" e)
  241. "Не смог :(")))
  242. (:otherwise "Добавляю только в git журнал :("))
  243. "Добавь git журнал.")))
  244. (defun ledger/new-chat-entry (chat-id entry)
  245. (bot-send-message
  246. chat-id (format-entry entry)
  247. :parse-mode "markdown"
  248. :reply-markup (keyboard/entry chat-id entry)))
  249. (defun entry-edit (chat-id message-id entry)
  250. (telegram-edit-message-text
  251. (format-entry entry)
  252. :chat-id chat-id :message-id message-id
  253. :parse-mode "markdown"
  254. :reply-markup (keyboard/edit-entry chat-id entry)))
  255. (defun keyboard/entry (chat-id entry)
  256. (get-inline-keyboard
  257. chat-id
  258. (list
  259. (list (inline-button ("➕")
  260. (telegram-answer-callback-query query-id :text "Добавляю...")
  261. (telegram-send-message source-chat-id (ledger/process-add-entry
  262. source-chat-id from entry))
  263. (telegram-edit-message-reply-markup
  264. nil :chat-id source-chat-id :message-id source-message-id))
  265. (inline-button ("💲")
  266. (entry-edit source-chat-id source-message-id entry))
  267. (inline-button ("✖️")
  268. (telegram-delete-message source-chat-id source-message-id))))))
  269. (defun def (str default)
  270. (if (or (null str) (string= "" str)) default
  271. str))
  272. (defun keyboard/edit-entry (chat-id entry)
  273. (get-inline-keyboard
  274. chat-id
  275. (append
  276. (list
  277. (list (inline-button ((format-date (pta-ledger:entry-date entry)))
  278. (bot-send-message chat-id "Введите дату"
  279. :reply-markup (telegram-force-reply))
  280. (on-next-message chat-id
  281. (let ((date (pta-ledger:parse-date text (get-year
  282. (pta-ledger:entry-date entry)))))
  283. (if date
  284. (progn
  285. (setf (pta-ledger:entry-date entry) date)
  286. (entry-edit chat-id source-message-id entry))
  287. (bot-send-message chat-id "Не разобрал")))))
  288. (inline-button ((def (pta-ledger:entry-description entry) "Описание"))
  289. (bot-send-message chat-id "Введите описание"
  290. :reply-markup (telegram-force-reply))
  291. (on-next-message chat-id
  292. (setf (pta-ledger:entry-description entry) text)
  293. (entry-edit chat-id source-message-id entry)))
  294. (inline-button ((def (pta-ledger:entry-comment entry) "Коммент"))
  295. (bot-send-message chat-id "Введите комментарий"
  296. :reply-markup (telegram-force-reply))
  297. (on-next-message chat-id
  298. (setf (pta-ledger:entry-comment entry) text)
  299. (entry-edit chat-id source-message-id entry)))))
  300. (loop for posting in (pta-ledger:entry-postings entry)
  301. collect
  302. (let ((this-posting posting))
  303. (list (inline-button ((pta-ledger:posting-account posting))
  304. (account-edit chat-id source-message-id entry this-posting
  305. (pta-ledger:posting-account this-posting)))
  306. (inline-button ((def (pta-ledger:render
  307. (pta-ledger:posting-amount posting))
  308. "Сумма"))
  309. (bot-send-message chat-id "Введите сумму"
  310. :reply-markup (telegram-force-reply))
  311. (on-next-message chat-id
  312. (let ((amount (pta-ledger:parse-amount text)))
  313. (setf (pta-ledger:posting-amount this-posting) amount)
  314. (entry-edit chat-id source-message-id entry)))))))
  315. (list (list (inline-button ("Готово")
  316. (telegram-edit-message-reply-markup
  317. (keyboard/entry chat-id entry)
  318. :chat-id chat-id :message-id source-message-id)))))))
  319. (defun accounts/nav (account accounts &optional (offset 0) (count 5))
  320. (let* ((len (1+ (length account)))
  321. (sep-pos (position #\: account :from-end t))
  322. (parent (when sep-pos (subseq account 0 sep-pos)))
  323. (head (when account (concatenate 'string account ":")))
  324. (descendants (remove-if #'(lambda (a)
  325. (when head
  326. (not (equal head
  327. (subseq a 0 (min (length a) len))))))
  328. accounts)))
  329. (if descendants
  330. (let* ((children (sort (remove-duplicates
  331. (mapcar #'(lambda (d)
  332. (subseq d 0 (or (position #\: d :start len)
  333. (length d))))
  334. descendants)
  335. :test #'equal) #'string<))
  336. (total (length children)))
  337. (when (stringp offset)
  338. (let ((off (position offset children :test #'equal)))
  339. (setf offset (if off (max 0 (- off (round count 2))) 0))))
  340. (values account parent
  341. (mapcar
  342. #'(lambda (c)
  343. (let* ((needle (concatenate 'string c ":"))
  344. (n-l (length needle)))
  345. (cons c
  346. (not
  347. (find needle accounts :test #'equal
  348. :key #'(lambda (a)
  349. (subseq a 0 (min n-l (length a)))))))))
  350. (subseq children offset
  351. (min total (+ offset count))))
  352. (unless (zerop offset) (max 0 (- offset count)))
  353. (when (< (+ offset count) total) (+ offset count))))
  354. (when parent (accounts/nav parent accounts account)))))
  355. (defun account-edit (chat-id message-id entry posting account &optional (offset 0))
  356. (with-chat-journal (chat-id journal updated)
  357. (let* ((accounts (pta-ledger:journal-accounts journal))
  358. (nav-list (multiple-value-list
  359. (accounts/nav account accounts offset))))
  360. (telegram-edit-message-reply-markup
  361. (apply #'keyboard/account chat-id entry posting nav-list)
  362. :chat-id chat-id :message-id message-id))))
  363. (defun keyboard/account (chat-id entry posting account parent children prev next)
  364. (get-inline-keyboard
  365. chat-id
  366. (append
  367. (list (list (when account
  368. (inline-button (account)
  369. (setf (pta-ledger:posting-account posting) account)
  370. (entry-edit chat-id source-message-id entry)))
  371. (inline-button ("Ввести")
  372. (bot-send-message chat-id "Введите счёт"
  373. :reply-markup (telegram-force-reply))
  374. (on-next-message chat-id
  375. (let ((account (pta-ledger:parse-account text)))
  376. (if account
  377. (progn
  378. (setf (pta-ledger:posting-account posting) account)
  379. (entry-edit chat-id source-message-id entry))
  380. (bot-send-message chat-id "Не разобрал")))))))
  381. (loop for (acc . leaf) in children
  382. collect (let ((this-account acc))
  383. (list (if leaf
  384. (inline-button (this-account)
  385. (setf (pta-ledger:posting-account posting) this-account)
  386. (entry-edit chat-id source-message-id entry))
  387. (inline-button ((format nil "~A ..." this-account))
  388. (account-edit chat-id source-message-id entry posting
  389. this-account))))))
  390. (list (list (when prev
  391. (inline-button ("<<")
  392. (account-edit chat-id source-message-id entry posting
  393. account prev)))
  394. (when (or parent account)
  395. (inline-button ((or parent "ACC"))
  396. (account-edit chat-id source-message-id entry posting
  397. parent account)))
  398. (when next
  399. (inline-button (">>")
  400. (account-edit chat-id source-message-id entry posting
  401. account next))))))))