tinkoff.lisp 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.tinkoff
  3. (:use :cl :chatikbot.common))
  4. (in-package :chatikbot.plugins.tinkoff)
  5. (defsetting *ua* "OnePlus ONE A2003/android: 6.0.1/TCSMB/3.4.2" "User agent")
  6. (defsetting *device-id* "1df9bdeac787e08")
  7. (defsetting *app-version* "3.4.2")
  8. (defvar *api-base-url* "https://api.tinkoff.ru/v1/")
  9. (defvar *session-id* nil "Last session id")
  10. (defvar *origin* "mobile,ib5,loyalty,platform")
  11. (defvar *platform* "android")
  12. (defvar *credentials-provider* nil "Active credentials provider")
  13. (defun api-url (method)
  14. (concatenate 'string *api-base-url* method))
  15. (defun default-params ()
  16. `(("origin" . ,*origin*)
  17. ("platform" . ,*platform*)
  18. ("deviceId" . ,*device-id*)
  19. ("appVersion" . ,*app-version*)
  20. ("sessionid" . ,*session-id*)
  21. ("ccc" . "true")))
  22. (define-condition api-error (error)
  23. ((code :initarg :code :reader api-error-code)
  24. (message :initarg :message :reader api-error-message))
  25. (:report (lambda (condition stream)
  26. (with-slots (code message) condition
  27. (format stream "Tinkoff api error ~A: ~A" code message)))))
  28. (defun request (method &key params content retry)
  29. (let* ((params (loop for (k . v) in (append (default-params) params) when v
  30. collect (cons (princ-to-string k) (princ-to-string v))))
  31. (r (json-request (api-url method) :method (if content :POST :GET)
  32. :parameters params
  33. :content content)))
  34. (if (string= "OK" (agets r "resultCode"))
  35. (agets r "payload")
  36. (let ((code (agets r "resultCode"))
  37. (message (agets r "errorMessage")))
  38. (if (and (not retry)
  39. *credentials-provider*
  40. (string-equal code "INSUFFICIENT_PRIVILEGES"))
  41. (progn
  42. (funcall *credentials-provider*
  43. (lambda (login password)
  44. (api/login login password)))
  45. (request method :params params :content content :retry t))
  46. (error 'api-error :code code :message message))))))
  47. (defun api/login (username password)
  48. (let ((new-session (request "session")))
  49. (prog1
  50. (let* ((*session-id* new-session))
  51. (request "sign_up" :params `(("username" . ,username)
  52. ("password" . ,password)))
  53. (request "level_up"))
  54. (setf *session-id* new-session))))
  55. (defun api/accounts ()
  56. (request "accounts_flat"))
  57. (defun api/operations (&key account start end)
  58. (request "operations" :params `(("account" . ,account)
  59. ("start" . ,start)
  60. ("end" . ,end))))
  61. (defvar *unix-epoch-difference*
  62. (encode-universal-time 0 0 0 1 1 1970 0))
  63. (defun universal-to-unix-time (universal-time)
  64. (- universal-time *unix-epoch-difference*))
  65. (defun unix-to-universal-time (unix-time)
  66. (+ unix-time *unix-epoch-difference*))
  67. (defun get-unix-time ()
  68. (universal-to-unix-time (get-universal-time)))
  69. (defun short-date (ms)
  70. (if ms
  71. (multiple-value-bind (sec min hour day month year)
  72. (decode-universal-time (unix-to-universal-time (round ms 1000)))
  73. (declare (ignore sec min hour))
  74. (format nil "~4,'0D/~2,'0D/~2,'0D" year month day))
  75. "-"))
  76. (defun get-op-description (op)
  77. (let ((cat (parse-integer (agets op "category" "id") :junk-allowed t)))
  78. (cond
  79. ((equal cat 16) (if (> (agets op "accountAmount" "value")
  80. 1500)
  81. "project: Volvo" "project: Smart"))
  82. (:otherwise
  83. (or
  84. (agets op "payment" "fieldsValues" "comment")
  85. (agets op "brand" "name"))))))
  86. (defun get-op-account1 (op)
  87. (case (parse-integer (agets op "account"))
  88. (5001173482 "assets:Tinkoff:Debit")
  89. (1850735 "assets:Raiffeisen:Debit")
  90. (0047479860 "liabilities:Tinkoff:Credit:Platinum")
  91. (8102961813 "assets:Tinkoff:Savings:For credit")
  92. (t (concatenate 'string "assets:Tinkoff:" (agets op "account")))))
  93. (defun get-op-account2 (op)
  94. (let ((cat (parse-integer (agets op "category" "id") :junk-allowed t)))
  95. (case cat
  96. (60 "expenses:Food:Fast-food")
  97. (36 "expenses:Transport")
  98. (32 "expenses:Food:Restaurant")
  99. (20 "expenses:Life:Wear")
  100. (16 "expenses:Transport:Car:Gas")
  101. (10 "expenses:Food:Grocery")
  102. (t (concatenate 'string
  103. (if (equal "Credit" (agets op "type"))
  104. "income:" "expenses:")
  105. (agets op "category" "name"))))))
  106. (defun ops->journal (ops)
  107. (loop for op in ops
  108. for status = (agets op "status")
  109. for date = (short-date (agets op "operationTime" "milliseconds"))
  110. for id = (agets op "ucid")
  111. for payee = (agets op "description")
  112. for description = (get-op-description op)
  113. for account-amount = (agets op "accountAmount" "value")
  114. for account-currency = (agets op "accountAmount" "currency" "name")
  115. for amount = (agets op "amount" "value")
  116. for currency = (agets op "amount" "currency" "name")
  117. for account1 = (get-op-account1 op)
  118. for account2 = (get-op-account2 op)
  119. for expense = (equal "Debit" (agets op "type"))
  120. unless (string= status "FAILED")
  121. collect (format nil "~A~@[ (~A)~] ~A~@[ ; ~A~]~% ~38A ~A~,2F ~A~@[ @@ ~{~A~,2F ~A~}~]~% ~38A ~A~,2F ~A"
  122. date nil payee (unless (equal payee description) description)
  123. account2 (if expense " " "-") amount currency
  124. (unless (equal currency account-currency)
  125. (list (if expense "" "-") account-amount account-currency))
  126. account1 (if expense "-" " ") account-amount account-currency)))
  127. (defun get-last-movements (begin-ut end-ut)
  128. (api/operations :start (* 1000 (universal-to-unix-time begin-ut))
  129. :end (* 1000 (universal-to-unix-time end-ut))))
  130. ;; Cron
  131. (defvar *last-movements* (make-hash-table) "Last per-chat movements")
  132. (defvar *chat-sessions* (make-hash-table) "Per-chat sessions")
  133. (defun get-chat-last-movements (chat-id &optional (offset +day+))
  134. (let* ((*session-id* (gethash chat-id *chat-sessions*))
  135. (*credentials-provider* (lambda (authenticator)
  136. (with-secret (login-pass (list :tinkoff chat-id))
  137. (if login-pass
  138. (apply authenticator login-pass)
  139. (error "no tinkoff credentials for ~A" chat-id)))))
  140. (now (get-universal-time))
  141. (pre (- now offset))
  142. (new (get-last-movements pre now)))
  143. (when new
  144. (setf (gethash chat-id *chat-sessions*) *session-id*))
  145. new))
  146. (defun format-changes (changes)
  147. (format nil "```~%~{~A~^~%~%~}```" (ops->journal changes)))
  148. (defcron process-tinkoff (:minute '(member 0 5 10 15 20 25 30 35 40 45 50 55))
  149. (dolist (chat-id (lists-get :tinkoff))
  150. (let ((old (gethash chat-id *last-movements*))
  151. (new (get-chat-last-movements chat-id (* 7 24 60 60))))
  152. (when new
  153. (log:info "Got ~A tinkoff events" (length new))
  154. (when old
  155. (alexandria:when-let (changes (set-difference new old :test #'equal))
  156. (bot-send-message chat-id (format-changes changes) :parse-mode "markdown")))
  157. (setf (gethash chat-id *last-movements*) new)))))
  158. (def-message-cmd-handler handler-tink (:tink)
  159. (let ((last (get-chat-last-movements chat-id (* (if args (parse-integer (car args)) 7) +day+))))
  160. (bot-send-message chat-id (format-changes last) :parse-mode "markdown")))