(in-package :cl-user)
(defpackage chatikbot.plugins.nalunch
(:use :cl :chatikbot.common))
(in-package :chatikbot.plugins.nalunch)
(defvar *nalunch/calend* nil "Working calendar exceptions")
(defparameter +nalunch/mobile-ua+ "Mozilla/5.0 (Linux; Android 4.4.4; Nexus 5 Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/38.0.2125.114 Mobile Safari/537.36"
"Mobile UA")
(defparameter +nalunch/mobile-uri+ "https://www.nalunch.ru/Mobile")
(defparameter +nalunch/login-uri+ "https://www.nalunch.ru/Mobile/Account/Login")
(defparameter +nalunch/basicdata-calend+ "http://basicdata.ru/api/json/calend/")
(defun nalunch/auth (login pass cookies &optional dom)
(let* ((dom (or dom
(xml-request +nalunch/login-uri+ :cookie-jar cookies :user-agent +nalunch/mobile-ua+)))
(form (plump:get-element-by-id dom "LoginForm"))
(parameters
(loop for input in (get-by-tag form "input")
for name = (plump:get-attribute input "name")
for value = (plump:get-attribute input "value")
when (and name value) collect (cons name value)
when (string= name "UserName") collect (cons name login)
when (string= name "Password") collect (cons name pass))))
(multiple-value-bind (response status response-headers)
(http-request +nalunch/login-uri+
:method :post
:content parameters
:cookie-jar cookies
:user-agent +nalunch/mobile-ua+)
(when (and (member status '(301 302 303 307) :test #'=)
(gethash "location" response-headers))
(setf response (http-request (quri:merge-uris
(quri:uri (gethash "location" response-headers))
(quri:uri +nalunch/login-uri+))
:cookie-jar cookies
:user-agent +nalunch/mobile-ua+)))
(when (search "id=\"LoginForm\"" response)
(error "Bad username or password"))
(if (search "
Чек" response) ;; Reload feed page on 'Cheque'
(xml-request +nalunch/mobile-uri+ :cookie-jar cookies :user-agent +nalunch/mobile-ua+)
(plump:parse response)))))
(defun nalunch/recent (login pass &optional cookies)
(let ((cookies (or cookies (cl-cookie:make-cookie-jar))))
(multiple-value-bind (dom status headers uri)
(xml-request +nalunch/mobile-uri+ :cookie-jar cookies :user-agent +nalunch/mobile-ua+)
(declare (ignore status headers))
(let* ((dom (if (quri:uri= uri (quri:uri +nalunch/mobile-uri+))
dom
(nalunch/auth login pass cookies dom)))
(balance (parse-integer (plump:text (elt (clss:select ".newswire-header_balance" dom) 0))))
(recent (loop for day across (clss:select ".day-feed" dom)
append (loop for el across (clss:select ".media" day)
for date = (select-text day ".day-feed_date")
for time = (select-text el ".transaction_time")
for price = (parse-float (select-text el ".transaction_price"))
for place = (select-text el ".transaction-title")
collect (list (cons :date date)
(cons :time time)
(cons :price price)
(cons :place place))))))
(list (cons :balance balance)
(cons :recent recent))))))
(defsetting *currency* "RUB")
(defsetting *expense-account* "expenses:Food:Work")
(defsetting *liabilities-account* "liabilities:nalunch")
(defparameter +months+ '("" "января" "февраля" "марта" "апреля" "мая" "июня" "июля" "августа" "сентября" "октября" "ноября" "декабря"))
(defun date-time->ut (date time)
(let* ((decoded-now (multiple-value-list
(decode-universal-time (get-universal-time) *chat-default-timezone*)))
(year (nth 5 decoded-now))
(hour (parse-integer time :start 0 :end 2))
(minute (parse-integer time :start 3 :end 5))
(day (if (string= date "Сегодня")
(nth 3 decoded-now)
(parse-integer date :start 0 :end 2)))
(month (if (string= date "Сегодня")
(nth 4 decoded-now)
(position (subseq date 3) +months+ :test #'equal))))
(encode-universal-time 0 minute hour day month year *chat-default-timezone*)))
(defun recent->entry (recent)
(let* ((pta-ledger (find-package :pta-ledger))
(make-entry (symbol-function (intern "MAKE-ENTRY" pta-ledger)))
(make-posting (symbol-function (intern "MAKE-POSTING" pta-ledger)))
(make-amount (symbol-function (intern "MAKE-AMOUNT" pta-ledger)))
(date (date-time->ut (agets recent :date) (agets recent :time)))
(payee (agets recent :place))
(amount (agets recent :price)))
(funcall make-entry
:date date
:description payee
:postings (list
(funcall make-posting
:account *expense-account*
:amount (funcall make-amount
:quantity amount
:commodity *currency*))
(funcall make-posting
:account *liabilities-account*
:amount (funcall make-amount
:quantity (* -1 amount)
:commodity *currency*))))))
(defun %nalunch/get-calend (year)
(setf year (princ-to-string year))
(unless (aget year *nalunch/calend*)
(setf *nalunch/calend* (aget "data" (json-request +nalunch/basicdata-calend+))))
(aget year *nalunch/calend*))
(defun %nalunch/get-working-days (year month)
(let* ((exceptions (aget (princ-to-string month) (%nalunch/get-calend year)))
(days-in-month (local-time:days-in-month month year)))
(loop for day from 1 upto days-in-month
for ts = (local-time:encode-timestamp 0 0 0 0 day month year)
for dof = (local-time:timestamp-day-of-week ts)
for exc = (aget "isWorking" (aget (princ-to-string day) exceptions))
when (or (and (<= 1 dof 5)
(not (equal 2 exc)))
(and (or (= dof 0) (= dof 6))
(or (equal 0 exc)
(equal 3 exc))))
collect day)))
(defun %nalunch/format (result &optional last)
(let* ((balance (aget :balance result))
(all (aget :recent result))
(recent (cons (car all) (unless last (cdr all))))
(now (local-time:now))
(left-working-days (length (remove-if #'(lambda (d) (<= d (local-time:timestamp-day now)))
(%nalunch/get-working-days (local-time:timestamp-year now)
(local-time:timestamp-month now))))))
(format nil "🍴 Баланс ~A руб~@[ на ~A дней, по ~$ руб~].~{~&~A~}"
balance left-working-days (/ balance (max left-working-days 1))
(mapcar (lambda (meal) (format nil "~A ~A @ ~A — ~A руб."
(aget :date meal) (aget :time meal)
(aget :place meal) (aget :price meal)))
recent))))
;; Cron
(defvar *nalunch/last-results* (make-hash-table) "Last check results")
(defvar *nalunch/jars* (make-hash-table) "Cookie jars")
(defcron process-nalunch (:minute '(member 0 10 20 30 40 50))
(dolist (chat-id (lists-get :nalunch))
(with-secret (login-pass (list :nalunch chat-id))
(if login-pass
(let* ((cookie-jar (or (gethash chat-id *nalunch/jars*)
(cl-cookie:make-cookie-jar)))
(ledger-package (find-package :chatikbot.plugins.ledger))
(old (gethash chat-id *nalunch/last-results*))
(new (nalunch/recent (car login-pass) (cdr login-pass) cookie-jar)))
(when new
(when (and old (not (equal (aget :balance old)
(aget :balance new))))
(bot-send-message chat-id (%nalunch/format new t))
(when ledger-package
(let ((new-chat-entry (symbol-function
(intern "LEDGER/NEW-CHAT-ENTRY" ledger-package))))
(dolist (recent (set-difference (agets new :recent) (agets old :recent) :test #'equalp))
(funcall new-chat-entry chat-id (recent->entry recent))))))
(setf (gethash chat-id *nalunch/last-results*) new
(gethash chat-id *nalunch/jars*) cookie-jar)))
(progn
(log:warn "nalunch no login/pass for" chat-id))))))
;; Hooks
(defun nalunch/handle-set-cron (chat-id enable)
(lists-set-entry :nalunch chat-id enable)
(bot-send-message chat-id
(if enable
"Включил рассылку. '/nalunch off' чтобы выключить, /nalunch - показать последние."
"Без рассылки. '/nalunch on' - включить, /nalunch - последние.")))
(defun nalunch/handle-auth (chat-id login pass)
(let ((cookies (cl-cookie:make-cookie-jar)))
(handler-case
(progn
(nalunch/auth login pass cookies)
(secret-set `(:nalunch ,chat-id) (cons login pass))
(nalunch/handle-set-cron chat-id t))
(error () (bot-send-message chat-id "Чот не смог, пропробуй другие.")))))
(defun nalunch/handle-recent (chat-id)
(with-secret (login-pass (list :nalunch chat-id))
(bot-send-message chat-id
(if login-pass
(let* ((cookies (or (gethash chat-id *nalunch/jars*)
(cl-cookie:make-cookie-jar)))
(data (nalunch/recent (car login-pass) (cdr login-pass) cookies)))
(if data
(progn
(setf (gethash chat-id *nalunch/jars*) cookies)
(%nalunch/format data))
"Не смог получить данные. Попробуй перелогинься. /nalunch "))
"Нужен логин-пароль. /nalunch ")
:parse-mode "markdown")))
(def-message-cmd-handler handle-cmd-nalunch (:nalunch)
(cond
((= 1 (length args))
(nalunch/handle-set-cron chat-id (equal "on" (car args))))
((= 2 (length args)) (apply 'nalunch/handle-auth chat-id args))
(:otherwise (nalunch/handle-recent chat-id))))