1
0
Innocenty Enikeew 9 лет назад
Родитель
Сommit
9b9d764209
12 измененных файлов с 523 добавлено и 568 удалено
  1. 30 0
      admin.lisp
  2. 1 0
      chatikbot.asd
  3. 25 567
      chatikbot.lisp
  4. 47 0
      finance.lisp
  5. 25 0
      forecast.lisp
  6. 80 1
      foursquare.lisp
  7. 10 0
      google.lisp
  8. 51 0
      hooks.lisp
  9. 20 0
      nalunch.lisp
  10. 83 0
      rss.lisp
  11. 12 0
      utils.lisp
  12. 139 0
      vk.lisp

+ 30 - 0
admin.lisp

@@ -0,0 +1,30 @@
+(in-package #:chatikbot)
+
+(defmacro handling-errors (&body body)
+  `(handler-case (progn ,@body)
+     (simple-condition (err)
+       (format *error-output* "~&~A: ~%" (class-name (class-of err)))
+       (apply (function format) *error-output*
+              (simple-condition-format-control   err)
+              (simple-condition-format-arguments err))
+       (format *error-output* "~&"))
+     (condition (err)
+       (format *error-output* "~&~A: ~%  ~S~%"
+               (class-name (class-of err)) err))))
+
+(defun rep (input)
+  (when input
+    (with-output-to-string (*standard-output*)
+      (let ((*package* (find-package 'chatikbot))
+            (*error-output* *standard-output*))
+        (handling-errors
+          (format t "~{~S~^ ;~%     ~}~%"
+                  (multiple-value-list (eval (read-from-string input)))))))))
+
+(def-message-handler handler-admin (message)
+  (when (member from-id *admins*)
+    (multiple-value-bind (cmd args) (parse-cmd text)
+      (case cmd
+        (:eval (bot-send-message chat-id (rep (format nil "~{~A~^ ~}" args))))
+        (otherwise (send-dont-understand chat-id (preprocess-input (subseq text 1))))))
+    t))

+ 1 - 0
chatikbot.asd

@@ -22,6 +22,7 @@
   :serial t
   :serial t
   :components ((:file "package")
   :components ((:file "package")
                (:file "utils")
                (:file "utils")
+               (:file "hooks")
                (:file "db")
                (:file "db")
                (:file "telegram")
                (:file "telegram")
                (:file "forecast")
                (:file "forecast")

+ 25 - 567
chatikbot.lisp

@@ -13,6 +13,8 @@
 (defvar *telegram-last-update* nil "Telegram last update_id")
 (defvar *telegram-last-update* nil "Telegram last update_id")
 (defvar *admins* nil "Admins chat-ids")
 (defvar *admins* nil "Admins chat-ids")
 
 
+
+;; getUpdates handling
 (defun process-updates ()
 (defun process-updates ()
   (loop for update in (telegram-get-updates :offset (and *telegram-last-update*
   (loop for update in (telegram-get-updates :offset (and *telegram-last-update*
                                                          (1+ *telegram-last-update*))
                                                          (1+ *telegram-last-update*))
@@ -20,9 +22,14 @@
      do (setf *telegram-last-update*
      do (setf *telegram-last-update*
               (max (or *telegram-last-update* 0)
               (max (or *telegram-last-update* 0)
                    (aget "update_id" update)))
                    (aget "update_id" update)))
-     do (handle-message (aget "message" update))))
-
-
+     do (handle-update update)))
+
+(defun handle-update (update)
+  (log:info update)
+  (loop for (key . value) in update
+     unless (equal "update_id" key)
+     do (run-update-hooks (key-to-hook-name key) value)))
+;;
 (defun send-response (chat-id response &optional reply-id)
 (defun send-response (chat-id response &optional reply-id)
   (if (consp response)
   (if (consp response)
       (if (keywordp (car response))
       (if (keywordp (car response))
@@ -33,576 +40,27 @@
           (mapc #'(lambda (r) (send-response chat-id r reply-id)) response))
           (mapc #'(lambda (r) (send-response chat-id r reply-id)) response))
       (telegram-send-message chat-id response :reply-to reply-id)))
       (telegram-send-message chat-id response :reply-to reply-id)))
 
 
+(defun bot-send-message (chat-id text &key parse-mode disable-web-preview reply-to reply-markup)
+  (handler-case (telegram-send-message chat-id text :parse-mode parse-mode
+                                       :disable-web-preview disable-web-preview
+                                       :reply-to reply-to
+                                       :reply-markup reply-markup)
+    (error (e)
+      (log:error e))))
+
 (defun send-dont-understand (chat-id &optional text reply-id)
 (defun send-dont-understand (chat-id &optional text reply-id)
   (let ((resp (eliza text)))
   (let ((resp (eliza text)))
     (log:info text resp)
     (log:info text resp)
     (when resp
     (when resp
       (send-response chat-id resp reply-id))))
       (send-response chat-id resp reply-id))))
 
 
-(defvar *chat-locations* nil "ALIST of chat->location")
-
-(defun preprocess-input (text)
-  (when text
-    (let ((first-word (subseq text 0 (position #\Space text))))
-      (if (equal first-word "@chatikbot")
-          (preprocess-input (subseq text 11))
-          (replace-all text "@chatikbot" "ты")))))
-
-(defun parse-cmd (text)
-  (let* ((args (split-sequence:split-sequence #\Space (subseq text 1) :remove-empty-subseqs t))
-         (cmd (subseq (car args) 0 (position #\@ (car args)))))
-    (values (intern (string-upcase cmd) "KEYWORD") (rest args))))
-
-(defun handle-message (message)
-  (let ((id (aget "message_id" message))
-        (from-id (aget "id" (aget "from" message)))
-        (chat-id (aget "id" (aget "chat" message)))
-        (text (aget "text" message))
-        (location (aget "location" message))
-        (sticker (aget "file_id" (aget "sticker" message))))
-    (log:info "handle-message" message)
-    (when text
-      (if (equal #\/ (char text 0))
-          (multiple-value-bind (cmd args) (parse-cmd text)
-            (case cmd
-              (:postakb (handle-cmd-post-akb chat-id id args))
-              (:akb (handle-cmd-akb chat-id id args))
-              (:weather (handle-cmd-weather chat-id id args))
-              (:hourly (handle-cmd-weather chat-id id '("hourly")))
-              (:daily (handle-cmd-weather chat-id id '("daily")))
-              (:rates (handle-cmd-rates chat-id id args))
-              (:charts (handle-cmd-charts chat-id id args))
-              (:postcheckins (handle-cmd-post-checkins chat-id id args))
-              (:friends (handle-cmd-fsq-friends chat-id id args))
-              (:checkins (handle-cmd-checkins chat-id id args))
-              (:rss (handle-cmd-rss chat-id id args))
-              (:feeds (handle-cmd-feeds chat-id id args))
-              (:lastrss (handle-cmd-last-rss chat-id id args))
-              (:wall (handle-cmd-wall chat-id id args))
-              (:nalunch (handle-cmd-nalunch chat-id id args))
-              (:g (handle-cmd-google chat-id id args))
-              (:г (handle-cmd-google chat-id id args))
-              (otherwise (handle-admin-cmd from-id chat-id text cmd args))))
-          (send-dont-understand chat-id (preprocess-input text))))
-    (when location
-      (push (cons chat-id location) *chat-locations*)
-      (telegram-send-message chat-id "Взял на карандаш")
-      (save-settings))
-    (when sticker
-      (send-dont-understand chat-id))))
-
-(defmacro handling-errors (&body body)
-  `(handler-case (progn ,@body)
-     (simple-condition (err)
-       (format *error-output* "~&~A: ~%" (class-name (class-of err)))
-       (apply (function format) *error-output*
-              (simple-condition-format-control   err)
-              (simple-condition-format-arguments err))
-       (format *error-output* "~&"))
-     (condition (err)
-       (format *error-output* "~&~A: ~%  ~S~%"
-               (class-name (class-of err)) err))))
-
-(defun rep (input)
-  (when input
-    (with-output-to-string (*standard-output*)
-      (let ((*package* (find-package 'chatikbot))
-            (*error-output* *standard-output*))
-        (handling-errors
-          (format t "~{~S~^ ;~%     ~}~%"
-                  (multiple-value-list (eval (read-from-string input)))))))))
-
-(defun handle-admin-cmd (from-id chat-id text cmd args)
-  (if (find from-id *admins*)
-      (case cmd
-        (:eval (telegram-send-message chat-id (rep (format nil "~{~A~^ ~}" args))))
-        (otherwise (send-dont-understand chat-id (preprocess-input (subseq text 1)))))
-      (send-dont-understand chat-id (preprocess-input (subseq text 1)))))
-
-
-;; AKB
-(defparameter +akb-vk-domain+ "baneks" "VK.com username of 'B-category anekdotes'")
-(defvar *akb-send-to* nil "List of chat-id's to send AKBs to")
-
-(defun handle-cmd-post-akb (chat-id message-id args)
-  (log:info "handle-cmd-post-akb" chat-id message-id args)
-  (let ((message "Хуярим аники"))
-    (if (member chat-id *akb-send-to*)
-        (setf message "Не хуярим больше аники"
-              *akb-send-to* (set-difference *akb-send-to*
-                                            (list chat-id)))
-        (setf *akb-send-to* (cons chat-id *akb-send-to*)))
-    (telegram-send-message chat-id message)
-    (save-settings)))
-
-(defvar *akb-max-count* 5 "Max number of tweets to return per run")
-(defvar *akb-last-id* 0 "id of last AKB tweet")
-
-(defun format-akb (post)
-  (let* ((id (aget "id" post))
-         (url (format nil "https://vk.com/~A?w=wall~A_~A"
-                      +akb-vk-domain+ (aget "from_id" post) id)))
-    (format nil "~A~%~A" (aget "text" post) url)))
-
-(defun process-latest-akb ()
-  (handler-case
-      (dolist (post (reverse (aget "items" (vk-wall-get :domain +akb-vk-domain+
-                                                        :count *akb-max-count*))))
-        (let ((id (aget "id" post)))
-          (when (> id *akb-last-id*)
-            (send-akb (format-akb post))
-            (setf *akb-last-id* id)
-            (save-settings))))
-    (error (e) (log:error "~A" e))))
-
-(defun send-akb (text)
-  (log:info "send-akb: ~A" text)
-  (dolist (chat-id *akb-send-to*)
-    (handler-case
-        (telegram-send-message chat-id text
-                               :disable-web-preview 1)
-      (error (e) (log:error "~A" e)))))
-
-(defun handle-cmd-akb (chat-id message-id args)
-  (log:info "handle-cmd-akb" chat-id message-id args)
-  (handler-case
-      (progn
-    (let ((total-aneks
-           (aget "count" (vk-wall-get :domain +akb-vk-domain+ :count 1 :offset 10000000))))
-      (dolist (post (aget "items" (vk-wall-get :domain +akb-vk-domain+
-                           :count (or (ignore-errors (parse-integer (car args))) 1)
-                           :offset (random total-aneks))))
-        (handler-case
-        (telegram-send-message chat-id
-                       (format-akb post)
-                       :disable-web-preview 1)
-          (error (e) (log:error e))))))
-    (error (e)
-      (log:error "~A" e)
-      (telegram-send-message chat-id "Ошибочка вышла"))))
-
-;; Finance
-(defun process-rates ()
-  (handler-case
-      (let ((ts (local-time:timestamp-to-unix (local-time:now)))
-            (rates (get-rates))
-            (brent (get-brent))
-            (btc (get-btc-e)))
-        (db-add-finance ts (aget "USD/RUB" rates) (aget "EUR/RUB" rates) (aget "GBP/RUB" rates) brent btc))
-    (error (e) (log:error "~A" e))))
-
-(defun handle-cmd-rates (chat-id message-id args)
-  (log:info "handle-cmd-rates" chat-id message-id args)
-  (multiple-value-bind (ts usd eur gbp brent btc) (db-get-last-finance)
-    (telegram-send-message chat-id
-                           (format nil "Зеленый *~,2F*, гейро *~,2F*, британец *~,2F*, чёрная *~,2F*, btc *~,2F* @ _~A_"
-                                   usd eur gbp brent btc
-                                   (format-ts (local-time:unix-to-timestamp ts)))
-                           :parse-mode "Markdown")))
-
-(defparameter +chart-ranges+ (list (cons "day" (* 24 60))
-                                   (cons "week" (* 7 24 60))
-                                   (cons "month" (* 30 24 60))
-                                   (cons "quarter" (* 91 24 60))
-                                   (cons "year" (* 365 24 60))))
-
-(defun handle-cmd-charts (chat-id message-id args)
-  (log:info "handle-cmd-charts" chat-id message-id args)
-  (telegram-send-chat-action chat-id "upload_photo")
-  (handler-case
-      (let* ((args (mapcar 'string-downcase args))
-             (all-fields (mapcar #'car +db-finance-map+))
-             (fields (or (intersection args all-fields :test 'equal) all-fields))
-             (day-range (some #'(lambda (a) (aget a +chart-ranges+)) args))
-             (number (some #'(lambda (a) (parse-integer a :junk-allowed t)) args))
-             (avg (* 60 (cond
-                          (day-range (round day-range *chart-points*))
-                          (number)
-                          (:otherwise 1))))
-             (after-ts (local-time:timestamp- (local-time:now)
-                                              (* avg *chart-points*) :sec))
-             (rates (multiple-value-list (db-get-last-finance)))
-             (chart (apply #'make-chart (multiple-value-list
-                                         (db-get-series after-ts fields avg)))))
-        (telegram-send-photo chat-id chart
-                             :caption
-                             (format nil "Зеленый ~,2F, гейро ~,2F, британец ~,2F, чёрная ~,2F, btc ~,2F @ ~A"
-                                     (elt rates 1) (elt rates 2) (elt rates 3) (elt rates 4) (elt rates 5)
-                                     (format-ts (local-time:unix-to-timestamp (elt rates 0))))))
-    (error (e)
-      (log:error "~A" e)
-      (telegram-send-message chat-id "Хуйня какая-то"))))
-
-;; Weather
-(defun handle-cmd-weather (chat-id message-id args)
-  (log:info "handle-cmd-weather" chat-id message-id args)
-  (let ((location (cdr (assoc chat-id *chat-locations*))))
-    (telegram-send-message
-     chat-id
-     (if location
-     (handler-case
-         (forecast-format (forecast
-                   (aget "latitude" location)
-                   (aget "longitude" location)
-                   :hourly (find "hourly" args :key #'string-downcase :test #'equal)
-                   :daily (find "daily" args :key #'string-downcase :test #'equal)))
-       (error (e)
-         (log:error "~A" e)
-         "Ошибочка вышла"))
-     "Так а ты чьих будешь?"))))
-
-
-;; Foursquare
-(defun fsq-user-name (user)
-  (when user
-    (format nil "~@[~A~]~@[ ~A~]"
-            (aget "firstName" user)
-            (aget "lastName" user))))
-
-(defun handle-cmd-post-checkins (chat-id message-id args)
-  (log:info "handle-cmd-post-checkins" chat-id message-id args)
-  (handler-case
-      (let ((users (db-fsq-get-chat-users chat-id))
-            (friends (fsq-fetch-friends)))
-        (if (null args)
-            (telegram-send-message chat-id
-                                   (if (null users)
-                                       "Пока никого не палим"
-                                       (format nil "Палим ~{~A~^, ~}"
-                                               (loop for user in friends
-                                                  when (member (aget "id" user)
-                                                               users :test #'equal)
-                                                  collect (fsq-user-name user)))))
-            (progn
-              (dolist (user args)
-                (let ((username (fsq-user-name
-                                 (find user friends
-                                       :test #'equal
-                                       :key #'(lambda (f) (aget "id" f))))))
-                  (when username
-                    (if (member user users :test #'equal)
-                        (progn
-                          (setf users (remove user users :test #'equal))
-                          (telegram-send-message chat-id
-                                                 (format nil "Больше не палим ~A" username)))
-                        (progn
-                          (push user users)
-                          (telegram-send-message chat-id (format nil "Теперь палим ~A" username)))))))
-              (db-fsq-set-chat-users chat-id users))))
-    (error (e)
-      (log:error "~A" e)
-      (telegram-send-message chat-id "Ошибочка вышла"))))
-
-(defun handle-cmd-fsq-friends (chat-id message-id args)
-  (log:info "handle-cmd-fsq-friends" chat-id message-id args)
-  (handler-case
-      (let ((users (db-fsq-get-chat-users chat-id))
-            (friends (fsq-fetch-friends)))
-        (telegram-send-message
-         chat-id
-         (format
-          nil "~{~A: ~:[~;📍 ~]~A~^~%~}"
-          (loop for user in friends
-             append (list
-                     (aget "id" user)
-                     (member (aget "id" user) users :test #'equal)
-                     (fsq-user-name user))))))
-    (error (e) (log:error "~A" e))))
-
-
-(defun handle-cmd-checkins (chat-id message-id args)
-  (log:info "handle-cmd-checkins" chat-id message-id args)
-  (handler-case
-      (let ((users (db-fsq-get-chat-users chat-id)))
-        (when users
-          (telegram-send-message
-           chat-id
-           (format nil "~{~A~^~%~}"
-                   (loop for checkin in (fsq-fetch-checkins)
-                      if (member (aget "id" (aget "user" checkin)) users :test #'equal)
-                      collect (fsq-format-checkin checkin t))))))
-    (error (e) (log:error "~A" e))))
-
-
-(defun process-latest-checkins ()
-  (handler-case
-      (let ((checkins (make-hash-table))
-            (ts (princ-to-string (1+ (or (db-fsq-last-created) -1)))))
-        (dolist (checkin (fsq-fetch-checkins ts))
-          (let ((id (aget "id" checkin))
-                (created-at (aget "createdAt" checkin))
-                (user (aget "id" (aget "user" checkin))))
-            (unless (db-fsq-has-seen id)
-              (dolist (chat-id (db-fsq-get-user-chats user))
-                (push (fsq-format-checkin checkin)
-                      (gethash chat-id checkins)))
-              (db-fsq-add-seen id created-at))))
-        (loop for chat-id being the hash-keys in checkins using (hash-value texts)
-           do (log:info "Sending checkins" chat-id texts)
-             (telegram-send-message chat-id (format nil "~{~A~^~%~}" texts))))
-    (error (e) (log:error "~A" e))))
-
-
-;; RSS
-(defun handle-cmd-feeds (chat-id message-id args)
-  (log:info "handle-cmd-feeds" chat-id message-id args)
-  (handler-case
-      (telegram-send-message
-       chat-id
-       (if (null args)
-           "URL давай"
-           (format nil "~:[Не нашел RSS там~;~:*~{~{~A - ~A~}~^~%~}~]"
-                   (find-rss-links (car args))))
-       :disable-web-preview 1)
-    (condition (e)
-      (log:error "~A" e)
-      (telegram-send-message chat-id "Ошибочка вышла"))))
-
-(defun %send-feeds (chat-id feeds)
-  (telegram-send-message
-   chat-id
-   (if (null feeds)
-       "Пока ничего не постим"
-       (format nil "Постим~%~{~A) ~A: ~A~^~%~}"
-               (loop for feed in feeds
-                  for index from 1
-                  append (list index (feed-title feed) (feed-url feed)))))
-   :disable-web-preview 1))
-
-(defun %fetch-new-items (feed)
-  (loop for item in (refresh-feed feed #'db-rss-item-exists)
-     do (db-rss-add-item item)
-     collect item))
-
-(defun %get-feed (url)
-  (when url
-    (or (db-rss-get-feed-by-url url)
-        (alexandria:when-let (feed (build-feed url))
-          (log:info "Added feed" feed)
-          (db-rss-add-feed feed)
-          (%fetch-new-items feed)
-          feed))))
-
-(defun handle-cmd-rss (chat-id message-id args)
-  (log:info "handle-cmd-rss" chat-id message-id args)
-  (handler-case
-      (let ((feeds (db-rss-get-chat-feeds chat-id)))
-        (if (null args)
-            (%send-feeds chat-id feeds)
-            (progn
-              (dolist (url args)
-                (handler-case
-                    (let ((idx (parse-integer url)))
-                      (when (<= idx (length feeds))
-                        (setf feeds (remove (nth (1- idx) feeds) feeds))))
-                  (parse-error ()
-                    (alexandria:when-let (feed (%get-feed
-                                                (or (cadar (find-rss-links url))
-                                                    url)))
-                      (let ((existing (find (feed-url feed) feeds :key #'feed-url :test #'equal)))
-                        (if existing
-                            (setf feeds (remove existing feeds))
-                            (push feed feeds)))))
-                  (error (e) (log:error "~A" e))))
-              (db-rss-set-chat-feeds chat-id feeds)
-              (%send-feeds chat-id (db-rss-get-chat-feeds chat-id)))))
-    (condition (e)
-      (log:error "~A" e)
-      (telegram-send-message chat-id (format nil "Ошибочка вышла: ~A" e)))))
-
-(defun handle-cmd-last-rss (chat-id message-id args)
-  (log:info "handle-cmd-last-rss" chat-id message-id args)
-  (handler-case
-      (let ((feeds (db-rss-get-chat-feeds chat-id)))
-        (if (null args)
-            (%send-feeds chat-id feeds)
-            (let* ((idx (1- (parse-integer (car args))))
-                   (limit (min 20 (if (> (length args) 1) (parse-integer (second args)) 5)))
-                   (items (db-rss-last-feed-items (nth idx feeds) limit)))
-              (telegram-send-message chat-id
-                                     (format nil "~{~A~^~%~%~}"
-                                             (mapcar #'format-feed-item items))
-                                     :parse-mode "Markdown"
-                                     :disable-web-preview 1))))
-    (error (e)
-      (log:error "~A" e)
-      (telegram-send-message chat-id "Ошибочка вышла"))))
-
-(defun process-feeds ()
-  (handler-case
-      (dolist (feed (remove-if-not #'need-fetch-p (db-rss-get-active-feeds)))
-        (log:info "Fetching new items" (feed-url feed))
-        (dolist (item (%fetch-new-items feed))
-          (dolist (chat-id (db-rss-get-feed-chats feed))
-            (telegram-send-message chat-id
-                                   (format-feed-item item)
-                                   :parse-mode "Markdown"
-                                   :disable-web-preview 1)))
-        (db-rss-update-feed feed)) ;; Update next fetch and period
-    (error (e) (log:error "~A" e))))
-
-;; VK walls
-(defun %send-domains (chat-id domains)
-  (telegram-send-message
-   chat-id
-   (if (null domains)
-       "Пока ничего не постим"
-       (format nil "Постим~%~{~A) https://vk.com/~A~^~%~}"
-               (loop for d in domains for i from 1 append (list i d))))
-   :disable-web-preview 1))
-
-(defun %find-vk-domain (url)
-  (let ((path (puri:uri-path (puri:parse-uri url))))
-    (if (equal #\/ (elt path 0))
-        (subseq path 1)
-        path)))
-
-(defun %ensure-domain (domain)
-  (let* ((res (vk-wall-get :domain domain :count 1))
-         (last-id (aget "id" (first (aget "items" res)))))
-    (db-vk-ensure-domain domain last-id)
-    domain))
-
-(defun %vk-find-best-photo (attach)
-  (when attach
-    (let* ((photo (aget "photo" attach))
-           (sizes (loop for (k . v) in photo
-                    when (equal "photo_" (subseq k 0 (min 6 (length k))))
-                    collect (cons (parse-integer (subseq k 6)) v))))
-      (cdr (assoc (apply #'max (mapcar #'car sizes)) sizes)))))
-
-(defun %vk-find-video (attach)
-  (when attach
-    (let ((video (aget "video" attach)))
-      (format nil "https://vk.com/video~A_~A"
-              (aget "owner_id" video)
-              (aget "id" video)))))
-
-(defun %vk-find-preview (attachments)
-  (labels ((find-type (type)
-             (find type attachments :key #'(lambda (a) (aget "type" a)) :test #'equal)))
-    (or
-     (%vk-find-best-photo (find-type "photo"))
-     (%vk-find-video (find-type "video")))))
-
-(defparameter +vk-link-scanner+ (cl-ppcre:create-scanner "\\[((id|club)\\d+)\\|([^\\]]*?)\\]") "vk linking regex")
-
-(defun %vk-post-text (post)
-  (let* ((history (aget "copy_history" post))
-         (reposts (loop for p in history
-                     collect (let* ((owner (aget "owner_id" p))
-                                    (type (if (> owner 0) "id" "club"))
-                                    (id (abs owner)))
-                               (format nil "[~A](https://vk.com/~A~A)"
-                                       (vk-get-name owner) type id)))))
-    (when history
-      (setf post (car (last history))))
-    (values
-     (cl-ppcre:regex-replace-all +vk-link-scanner+
-                                 (aget "text" post)
-                                 "[\\3](https://vk.com/\\1)")
-     (%vk-find-preview (aget "attachments" post))
-     reposts)))
-
-(defun %format-wall-post (domain name post)
-  (multiple-value-bind (text preview reposts) (%vk-post-text post)
-    (values
-     (format nil "~@[[✅](~A) ~][~A](https://vk.com/~A?w=wall~A_~A)~@[ ~{↩ ~A~}~]~@[ @ ~A~]~%~A"
-             preview name domain (aget "from_id" post) (aget "id" post)
-             reposts (format-ts (local-time:unix-to-timestamp (aget "date" post)))
-             text)
-     (if preview 0 1))))
-
-(defun handle-cmd-wall (chat-id message-id args)
-  (log:info "handle-cmd-wall" chat-id message-id args)
-  (handler-case
-      (let ((domains (db-vk-get-chat-domains chat-id)))
-        (if (null args)
-            (%send-domains chat-id domains)
-            (progn
-              (dolist (url args)
-                (handler-case
-                    (let ((idx (parse-integer url)))
-                      (db-vk-remove-chat-domain chat-id (nth (1- idx) domains)))
-                  (parse-error ()
-                    (let* ((domain (%ensure-domain (%find-vk-domain url)))
-                           (existing (find domain domains :test #'equal)))
-                      (if existing
-                          (db-vk-remove-chat-domain chat-id domain)
-                          (db-vk-add-chat-domain chat-id domain))))
-                  (error (e) (log:error "~A" e))))
-              (%send-domains chat-id (db-vk-get-chat-domains chat-id)))))
-    (error (e)
-      (log:error "~A" e)
-      (telegram-send-message chat-id (format nil "Ошибочка вышла: ~A" e)))))
-
-(defun process-walls ()
-  (handler-case
-      (loop for (domain last-id next-fetch period) in (db-vk-get-active-walls)
-         when (or (null next-fetch)
-                  (local-time:timestamp> (local-time:now) (local-time:unix-to-timestamp next-fetch)))
-         do (progn
-              (log:info "Fetching wall" domain)
-              (handler-case
-                  (let ((new-posts
-                         (remove last-id (reverse (aget "items" (vk-wall-get :domain domain)))
-                                 :test #'>= :key (lambda (p) (aget "id" p))))
-                        name)
-                    (setf period (adjust-period period (length new-posts)))
-                    (when new-posts
-                      (setf name (vk-get-name domain)))
-                    (dolist (post new-posts)
-                      (multiple-value-bind (text disable)
-                          (%format-wall-post domain name post)
-                        (dolist (chat-id (db-vk-get-domain-chats domain))
-                          (ignore-errors
-                            (telegram-send-message chat-id text
-                                                   :parse-mode "Markdown"
-                                                   :disable-web-preview disable))))
-                      (setf last-id (max last-id (aget "id" post)))))
-                (error (e) (log:error "~A" e)))
-              (db-vk-update-wall domain last-id
-                                 (local-time:timestamp-to-unix
-                                  (local-time:timestamp+ (local-time:now) period :sec))
-                                 period))) ;; Update last-id, next-fetch and period
-    (error (e) (log:error "~A" e))))
-
-(defun handle-cmd-nalunch (chat-id message-id args)
-  (log:info "handle-cmd-nalunch" chat-id message-id args)
-  (handler-case
-      (if (member chat-id *admins*)
-          (send-response chat-id (nalunch-format
-                                  (or *nalunch-last-result*
-                                      (setf *nalunch-last-result*
-                                            (nalunch-recent)))))
-          (send-dont-understand chat-id))
-    (error (e)
-      (log:error "~A" e)
-      (telegram-send-message chat-id (format nil "~A" e)))))
-
-(defvar *nalunch-last-result* nil "Last check result")
-(defun process-nalunch ()
-  (handler-case
-      (let ((result (nalunch-recent)))
-        (unless (equal (aget :balance *nalunch-last-result*)
-                       (aget :balance result))
-          (send-response (car *admins*) (nalunch-format result t))
-          (setf *nalunch-last-result* result)))
-    (error (e) (log:error "~A" e))))
-
-(defun handle-cmd-google (chat-id message-id args)
-  (log:info "handle-cmd-google" chat-id message-id args)
-  (handler-case
-      (telegram-send-message chat-id
-                             (google-format-search-results
-                              (subseq
-                               (google-search (format nil "~{~A~^ ~}" args))
-                               0 3))
-                             :parse-mode "markdown"
-                             :disable-web-preview 1)
-    (error (e)
-           (log:error "~A" e)
-           (telegram-send-message chat-id (format nil "~A" e)))))
+(defun handle-unknown-message (message)
+  (let ((chat-id (aget "id" (aget "chat" message)))
+        (text (aget "text" message)))
+    (log:info "handle-unknown-message" message)
+    (send-dont-understand chat-id (preprocess-input text))
+    t))
+(add-update-hook :message 'handle-unknown-message t)
 
 
 (defun process-watchdog ()
 (defun process-watchdog ()
   (ignore-errors
   (ignore-errors

+ 47 - 0
finance.lisp

@@ -7,6 +7,12 @@
 
 
 (defvar *rate-pairs* '("USDRUB" "EURRUB" "GBPRUB"))
 (defvar *rate-pairs* '("USDRUB" "EURRUB" "GBPRUB"))
 
 
+(defparameter +chart-ranges+ (list (cons "day" (* 24 60))
+                                   (cons "week" (* 7 24 60))
+                                   (cons "month" (* 30 24 60))
+                                   (cons "quarter" (* 91 24 60))
+                                   (cons "year" (* 365 24 60))))
+
 (defun get-rates (&optional (pairs *rate-pairs*))
 (defun get-rates (&optional (pairs *rate-pairs*))
   (let ((response (json-request
   (let ((response (json-request
                    +yahoo-url+
                    +yahoo-url+
@@ -68,3 +74,44 @@
                              :format fmt)))
                              :format fmt)))
       (adw-charting:set-axis :y "RUB" :draw-gridlines-p t :label-formatter "~,2F")
       (adw-charting:set-axis :y "RUB" :draw-gridlines-p t :label-formatter "~,2F")
       (adw-charting:save-file "chart.png"))))
       (adw-charting:save-file "chart.png"))))
+
+;; Cron
+(defun process-rates ()
+  (handler-case
+      (let ((ts (local-time:timestamp-to-unix (local-time:now)))
+            (rates (get-rates))
+            (brent (get-brent))
+            (btc (get-btc-e)))
+        (db-add-finance ts (aget "USD/RUB" rates) (aget "EUR/RUB" rates) (aget "GBP/RUB" rates) brent btc))
+    (error (e) (log:error "~A" e))))
+
+;;; Hooks
+(def-message-cmd-handler handler-rates (:rates)
+  (multiple-value-bind (ts usd eur gbp brent btc) (db-get-last-finance)
+    (bot-send-message chat-id
+                      (format nil "Зеленый *~,2F*, гейро *~,2F*, британец *~,2F*, чёрная *~,2F*, btc *~,2F* @ _~A_"
+                              usd eur gbp brent btc
+                              (format-ts (local-time:unix-to-timestamp ts)))
+                      :parse-mode "Markdown")))
+
+(def-message-cmd-handler handler-charts (:charts)
+  (telegram-send-chat-action chat-id "upload_photo")
+  (let* ((args (mapcar 'string-downcase args))
+         (all-fields (mapcar #'car +db-finance-map+))
+         (fields (or (intersection args all-fields :test 'equal) all-fields))
+         (day-range (some #'(lambda (a) (aget a +chart-ranges+)) args))
+         (number (some #'(lambda (a) (parse-integer a :junk-allowed t)) args))
+         (avg (* 60 (cond
+                      (day-range (round day-range *chart-points*))
+                      (number)
+                      (:otherwise 1))))
+         (after-ts (local-time:timestamp- (local-time:now)
+                                          (* avg *chart-points*) :sec))
+         (rates (multiple-value-list (db-get-last-finance)))
+         (chart (apply #'make-chart (multiple-value-list
+                                     (db-get-series after-ts fields avg)))))
+    (telegram-send-photo chat-id chart
+                         :caption
+                         (format nil "Зеленый ~,2F, гейро ~,2F, британец ~,2F, чёрная ~,2F, btc ~,2F @ ~A"
+                                 (elt rates 1) (elt rates 2) (elt rates 3) (elt rates 4) (elt rates 5)
+                                 (format-ts (local-time:unix-to-timestamp (elt rates 0)))))))

+ 25 - 0
forecast.lisp

@@ -69,3 +69,28 @@
             (forecast-format-currently (aget "currently" forecast) timezone)
             (forecast-format-currently (aget "currently" forecast) timezone)
             (forecast-format-hourly (aget "hourly" forecast) timezone)
             (forecast-format-hourly (aget "hourly" forecast) timezone)
             (forecast-format-daily (aget "daily" forecast) timezone))))
             (forecast-format-daily (aget "daily" forecast) timezone))))
+
+;;; Hooks
+(defvar *chat-locations* nil "ALIST of chat->location")
+
+(def-message-handler handler-location (message)
+  (let ((chat-id (aget "id" (aget "chat" message)))
+        (location (aget "location" message)))
+    (when location
+      (log:info "handler-location" chat-id location)
+      (push (cons chat-id location) *chat-locations*)
+      (bot-send-message chat-id "Взял на карандаш")
+      (save-settings)
+      t)))
+
+(def-message-cmd-handler handler-cmd-weather (:weather :hourly :daily)
+  (let* ((location (cdr (assoc chat-id *chat-locations*)))
+         (response (if location
+                       (forecast-format
+                        (forecast
+                         (aget "latitude" location)
+                         (aget "longitude" location)
+                         :hourly (find "hourly" (cons (string cmd) args) :key #'string-downcase :test #'equal)
+                         :daily (find "daily" (cons (string cmd) args) :key #'string-downcase :test #'equal)))
+                       "Так а ты чьих будешь?")))
+    (bot-send-message chat-id response)))

+ 80 - 1
foursquare.lisp

@@ -20,7 +20,7 @@
          (meta (aget "meta" resp)))
          (meta (aget "meta" resp)))
     (when (not (= 200 (aget "code" meta)))
     (when (not (= 200 (aget "code" meta)))
       (error (format nil "Foursquare API error, code ~A, errorType '~A', errorDetail '~A'"
       (error (format nil "Foursquare API error, code ~A, errorType '~A', errorDetail '~A'"
-                     (aget "code" meta) (aget "errorType" meta) (aget "errorDetail" meta))))    
+                     (aget "code" meta) (aget "errorType" meta) (aget "errorDetail" meta))))
     (aget "response" resp)))
     (aget "response" resp)))
 
 
 (defun fsq-fetch-checkins (&optional after-timestamp limit)
 (defun fsq-fetch-checkins (&optional after-timestamp limit)
@@ -37,6 +37,14 @@
                (%fsq-api-call "users/self/friends"
                (%fsq-api-call "users/self/friends"
                               (list (cons "offset" (or offset "0"))))))))
                               (list (cons "offset" (or offset "0"))))))))
 
 
+;; Formatting
+
+(defun fsq-user-name (user)
+  (when user
+    (format nil "~@[~A~]~@[ ~A~]"
+            (aget "firstName" user)
+            (aget "lastName" user))))
+
 (defun fsq-format-checkin (checkin &optional with-dates)
 (defun fsq-format-checkin (checkin &optional with-dates)
   (when checkin
   (when checkin
     (let ((user (aget "user" checkin))
     (let ((user (aget "user" checkin))
@@ -50,3 +58,74 @@
                nil
                nil
                (local-time:unix-to-timestamp (aget "createdAt" checkin))
                (local-time:unix-to-timestamp (aget "createdAt" checkin))
                :format '(:year "-" (:month 2) "-" (:day 2) " " (:hour 2) ":" (:min 2)))))))
                :format '(:year "-" (:month 2) "-" (:day 2) " " (:hour 2) ":" (:min 2)))))))
+
+;; Cron
+(defun process-latest-checkins ()
+  (handler-case
+      (let ((checkins (make-hash-table))
+            (ts (princ-to-string (1+ (or (db-fsq-last-created) -1)))))
+        (dolist (checkin (fsq-fetch-checkins ts))
+          (let ((id (aget "id" checkin))
+                (created-at (aget "createdAt" checkin))
+                (user (aget "id" (aget "user" checkin))))
+            (unless (db-fsq-has-seen id)
+              (dolist (chat-id (db-fsq-get-user-chats user))
+                (push (fsq-format-checkin checkin)
+                      (gethash chat-id checkins)))
+              (db-fsq-add-seen id created-at))))
+        (loop for chat-id being the hash-keys in checkins using (hash-value texts)
+           do (log:info "Sending checkins" chat-id texts)
+             (telegram-send-message chat-id (format nil "~{~A~^~%~}" texts))))
+    (error (e) (log:error "~A" e))))
+
+;; Hooks
+(def-message-cmd-handler handler-post-checkins (:postcheckins)
+  (let ((users (db-fsq-get-chat-users chat-id))
+        (friends (fsq-fetch-friends)))
+    (if (null args)
+        (bot-send-message chat-id
+                          (if (null users)
+                              "Пока никого не палим"
+                              (format nil "Палим ~{~A~^, ~}"
+                                      (loop for user in friends
+                                         when (member (aget "id" user)
+                                                      users :test #'equal)
+                                         collect (fsq-user-name user)))))
+        (progn
+          (dolist (user args)
+            (let ((username (fsq-user-name
+                             (find user friends
+                                   :test #'equal
+                                   :key #'(lambda (f) (aget "id" f))))))
+              (when username
+                (if (member user users :test #'equal)
+                    (progn
+                      (setf users (remove user users :test #'equal))
+                      (bot-send-message chat-id
+                                        (format nil "Больше не палим ~A" username)))
+                    (progn
+                      (push user users)
+                      (bot-send-message chat-id (format nil "Теперь палим ~A" username)))))))
+          (db-fsq-set-chat-users chat-id users)))))
+
+(def-message-cmd-handler handler-friends (:friends)
+  (let ((users (db-fsq-get-chat-users chat-id))
+        (friends (fsq-fetch-friends)))
+    (bot-send-message chat-id
+                      (format
+                       nil "~{~A: ~:[~;📍 ~]~A~^~%~}"
+                       (loop for user in friends
+                          append (list
+                                  (aget "id" user)
+                                  (member (aget "id" user) users :test #'equal)
+                                  (fsq-user-name user)))))))
+
+
+(def-message-cmd-handler handle-checkins (:checkins)
+  (let ((users (db-fsq-get-chat-users chat-id)))
+    (when users
+      (bot-send-message chat-id
+                        (format nil "~{~A~^~%~}"
+                                (loop for checkin in (fsq-fetch-checkins)
+                                   if (member (aget "id" (aget "user" checkin)) users :test #'equal)
+                                   collect (fsq-format-checkin checkin t)))))))

+ 10 - 0
google.lisp

@@ -25,3 +25,13 @@
                                   (aget :url result)
                                   (aget :url result)
                                   (replace-all (aget :desc result)
                                   (replace-all (aget :desc result)
                                                '(#\Newline) ""))))))
                                                '(#\Newline) ""))))))
+
+;;; Hooks
+(def-message-cmd-handler handler-cmd-google (:google :g :г :q)
+  (bot-send-message chat-id
+                    (google-format-search-results
+                     (subseq
+                      (google-search (format nil "~{~A~^ ~}" args))
+                      0 3))
+                    :parse-mode "markdown"
+                    :disable-web-preview 1))

+ 51 - 0
hooks.lisp

@@ -0,0 +1,51 @@
+(in-package #:chatikbot)
+
+(defvar *update-hooks* (make-hash-table) "Update hooks storage")
+
+(defun run-update-hooks (hook-name update)
+  (let ((hooks (gethash hook-name *update-hooks*)))
+    (labels ((try-handle (func)
+               (funcall func update)))
+      (unless (some #'try-handle hooks)
+        (log:info "unhandled" update)))))
+
+(defun add-update-hook (hook-name handler &optional append)
+  (let ((existing (gethash hook-name *update-hooks*))
+        (func (if (functionp handler) handler (symbol-function handler))))
+    (unless (member func existing)
+      (setf (gethash hook-name *update-hooks*)
+            (if append (append existing (list func))
+                (cons func existing))))))
+
+(defun delete-update-hook (hook-name handler)
+  (setf (gethash hook-name *update-hooks*)
+        (remove (if (functionp handler) handler (symbol-function handler))
+                (gethash hook-name *update-hooks*))))
+
+(defun key-to-hook-name (key)
+  (intern (string-upcase (substitute #\- #\_ key)) :keyword))
+
+(defmacro def-message-handler (name (message) &body body)
+  `(progn
+     (defun ,name (,message)
+       (let ((message-id (aget "message_id" ,message))
+             (from-id (aget "id" (aget "from" ,message)))
+             (chat-id (aget "id" (aget "chat" ,message)))
+             (text (aget "text" ,message)))
+         (declare (ignorable message-id from-id chat-id text))
+         (handler-case (progn ,@body)
+           (error (e)
+             (log:error "~A" e)
+             (bot-send-message chat-id
+                               (format nil "Ошибочка вышла~@[: ~A~]"
+                                       (when (member chat-id *admins*) e)))))))
+     (add-update-hook :message ',name)))
+
+(defmacro def-message-cmd-handler (name (&rest commands) &body body)
+  `(def-message-handler ,name (message)
+     (when (and text (equal #\/ (char text 0)))
+       (multiple-value-bind (cmd args) (parse-cmd text)
+         (when (member cmd (list ,@commands))
+           (log:info cmd message-id chat-id from-id args)
+           ,@body
+           t)))))

+ 20 - 0
nalunch.lisp

@@ -87,3 +87,23 @@
             (mapcar (lambda (meal) (format nil "~A @ ~A — ~A руб."
             (mapcar (lambda (meal) (format nil "~A @ ~A — ~A руб."
                                            (aget :time meal) (aget :place meal) (aget :price meal)))
                                            (aget :time meal) (aget :place meal) (aget :price meal)))
                     recent))))
                     recent))))
+
+;; Cron
+(defvar *nalunch-last-result* nil "Last check result")
+(defun process-nalunch ()
+  (handler-case
+      (let ((result (nalunch-recent)))
+        (unless (equal (aget :balance *nalunch-last-result*)
+                       (aget :balance result))
+          (send-response (car *admins*) (nalunch-format result t))
+          (setf *nalunch-last-result* result)))
+    (error (e) (log:error "~A" e))))
+
+;; Hooks
+(def-message-cmd-handler handler-cmd-nalunch (:nalunch)
+  (if (member chat-id *admins*)
+      (send-response chat-id (nalunch-format
+                              (or *nalunch-last-result*
+                                  (setf *nalunch-last-result*
+                                        (nalunch-recent)))))
+      (send-dont-understand chat-id)))

+ 83 - 0
rss.lisp

@@ -70,6 +70,30 @@
 (defun clean-text (text)
 (defun clean-text (text)
   (when text (trim-nil (plump:text (plump:parse text)))))
   (when text (trim-nil (plump:text (plump:parse text)))))
 
 
+(defun %send-feeds (chat-id feeds)
+  (bot-send-message chat-id
+                    (if (null feeds)
+                        "Пока ничего не постим"
+                        (format nil "Постим~%~{~A) ~A: ~A~^~%~}"
+                                (loop for feed in feeds
+                                   for index from 1
+                                   append (list index (feed-title feed) (feed-url feed)))))
+                    :disable-web-preview 1))
+
+(defun %fetch-new-items (feed)
+  (loop for item in (refresh-feed feed #'db-rss-item-exists)
+     do (db-rss-add-item item)
+     collect item))
+
+(defun %get-feed (url)
+  (when url
+    (or (db-rss-get-feed-by-url url)
+        (alexandria:when-let (feed (build-feed url))
+          (log:info "Added feed" feed)
+          (db-rss-add-feed feed)
+          (%fetch-new-items feed)
+          feed))))
+
 (defun fetch-feed-items (feed)
 (defun fetch-feed-items (feed)
   (let ((plump:*tag-dispatchers* plump:*xml-tags*))
   (let ((plump:*tag-dispatchers* plump:*xml-tags*))
     (loop for item in (get-by-tag (xml-request (feed-url feed)) "item")
     (loop for item in (get-by-tag (xml-request (feed-url feed)) "item")
@@ -104,3 +128,62 @@
 (defun feed-item-published-unix (item)
 (defun feed-item-published-unix (item)
   (alexandria:when-let (ts (feed-item-published item))
   (alexandria:when-let (ts (feed-item-published item))
     (local-time:timestamp-to-unix ts)))
     (local-time:timestamp-to-unix ts)))
+
+;; Cron
+(defun process-feeds ()
+  (handler-case
+      (dolist (feed (remove-if-not #'need-fetch-p (db-rss-get-active-feeds)))
+        (log:info "Fetching new items" (feed-url feed))
+        (dolist (item (%fetch-new-items feed))
+          (dolist (chat-id (db-rss-get-feed-chats feed))
+            (telegram-send-message chat-id
+                                   (format-feed-item item)
+                                   :parse-mode "Markdown"
+                                   :disable-web-preview 1)))
+        (db-rss-update-feed feed)) ;; Update next fetch and period
+    (error (e) (log:error "~A" e))))
+
+;; Hooks
+(def-message-cmd-handler handler-cmd-feeds (:feeds)
+  (bot-send-message
+   chat-id
+   (if (null args)
+       "URL давай"
+       (format nil "~:[Не нашел RSS там~;~:*~{~{~A - ~A~}~^~%~}~]"
+               (find-rss-links (car args))))
+   :disable-web-preview 1))
+
+(def-message-cmd-handler handler-cmd-rss (:rss)
+  (let ((feeds (db-rss-get-chat-feeds chat-id)))
+    (if (null args)
+        (%send-feeds chat-id feeds)
+        (progn
+          (dolist (url args)
+            (handler-case
+                (let ((idx (parse-integer url)))
+                  (when (<= idx (length feeds))
+                    (setf feeds (remove (nth (1- idx) feeds) feeds))))
+              (parse-error ()
+                (alexandria:when-let (feed (%get-feed
+                                            (or (cadar (find-rss-links url))
+                                                url)))
+                  (let ((existing (find (feed-url feed) feeds :key #'feed-url :test #'equal)))
+                    (if existing
+                        (setf feeds (remove existing feeds))
+                        (push feed feeds)))))
+              (error (e) (log:error "~A" e))))
+          (db-rss-set-chat-feeds chat-id feeds)
+          (%send-feeds chat-id (db-rss-get-chat-feeds chat-id))))))
+
+(def-message-cmd-handler handler-cmd-last-rss (:lastrss)
+  (let ((feeds (db-rss-get-chat-feeds chat-id)))
+    (if (null args)
+        (%send-feeds chat-id feeds)
+        (let* ((idx (1- (parse-integer (car args))))
+               (limit (min 20 (if (> (length args) 1) (parse-integer (second args)) 5)))
+               (items (db-rss-last-feed-items (nth idx feeds) limit)))
+          (telegram-send-message chat-id
+                                 (format nil "~{~A~^~%~%~}"
+                                         (mapcar #'format-feed-item items))
+                                 :parse-mode "Markdown"
+                                 :disable-web-preview 1)))))

+ 12 - 0
utils.lisp

@@ -83,6 +83,18 @@ is replaced with replacement."
        (nreverse (push (car cur) result)))
        (nreverse (push (car cur) result)))
     (push (car cur) result)))
     (push (car cur) result)))
 
 
+(defun preprocess-input (text)
+  (when text
+    (let ((first-word (subseq text 0 (position #\Space text))))
+      (if (equal first-word "@chatikbot")
+          (preprocess-input (subseq text 11))
+          (replace-all text "@chatikbot" "ты")))))
+
+(defun parse-cmd (text)
+  (let* ((args (split-sequence:split-sequence #\Space (subseq text 1) :remove-empty-subseqs t))
+         (cmd (subseq (car args) 0 (position #\@ (car args)))))
+    (values (intern (string-upcase cmd) "KEYWORD") (rest args))))
+
 (defun http-default (url)
 (defun http-default (url)
   (let ((uri (puri:uri url)))
   (let ((uri (puri:uri url)))
     (puri:render-uri
     (puri:render-uri

+ 139 - 0
vk.lisp

@@ -44,3 +44,142 @@
   (if (and (numberp id) (> id 0))
   (if (and (numberp id) (> id 0))
       (vk-get-user-name id)
       (vk-get-user-name id)
       (vk-get-group-name id)))
       (vk-get-group-name id)))
+
+;; Cron
+(defun process-walls ()
+  (handler-case
+      (loop for (domain last-id next-fetch period) in (db-vk-get-active-walls)
+         when (or (null next-fetch)
+                  (local-time:timestamp> (local-time:now) (local-time:unix-to-timestamp next-fetch)))
+         do (progn
+              (log:info "Fetching wall" domain)
+              (handler-case
+                  (let ((new-posts
+                         (remove last-id (reverse (aget "items" (vk-wall-get :domain domain)))
+                                 :test #'>= :key (lambda (p) (aget "id" p))))
+                        name)
+                    (setf period (adjust-period period (length new-posts)))
+                    (when new-posts
+                      (setf name (vk-get-name domain)))
+                    (dolist (post new-posts)
+                      (multiple-value-bind (text disable)
+                          (%format-wall-post domain name post)
+                        (dolist (chat-id (db-vk-get-domain-chats domain))
+                          (ignore-errors
+                            (telegram-send-message chat-id text
+                                                   :parse-mode "Markdown"
+                                                   :disable-web-preview disable))))
+                      (setf last-id (max last-id (aget "id" post)))))
+                (error (e) (log:error "~A" e)))
+              (db-vk-update-wall domain last-id
+                                 (local-time:timestamp-to-unix
+                                  (local-time:timestamp+ (local-time:now) period :sec))
+                                 period))) ;; Update last-id, next-fetch and period
+    (error (e) (log:error "~A" e))))
+
+;; Hooks
+(defparameter +akb-vk-domain+ "baneks" "VK.com username of 'B-category anekdotes'")
+
+(defun format-akb (post)
+  (let* ((id (aget "id" post))
+         (url (format nil "https://vk.com/~A?w=wall~A_~A"
+                      +akb-vk-domain+ (aget "from_id" post) id)))
+    (format nil "~A~%~A" (aget "text" post) url)))
+
+(def-message-cmd-handler handler-akb (:akb)
+  (let ((total-aneks
+         (aget "count" (vk-wall-get :domain +akb-vk-domain+ :count 1 :offset 10000000))))
+    (dolist (post (aget "items" (vk-wall-get :domain +akb-vk-domain+
+                                             :count (or (ignore-errors (parse-integer (car args))) 1)
+                                             :offset (random total-aneks))))
+      (bot-send-message chat-id (format-akb post) :disable-web-preview 1))))
+
+;; VK walls
+(defun %send-domains (chat-id domains)
+  (bot-send-message
+   chat-id
+   (if (null domains)
+       "Пока ничего не постим"
+       (format nil "Постим~%~{~A) https://vk.com/~A~^~%~}"
+               (loop for d in domains for i from 1 append (list i d))))
+   :disable-web-preview 1))
+
+(defun %find-vk-domain (url)
+  (let ((path (puri:uri-path (puri:parse-uri url))))
+    (if (equal #\/ (elt path 0))
+        (subseq path 1)
+        path)))
+
+(defun %ensure-domain (domain)
+  (let* ((res (vk-wall-get :domain domain :count 1))
+         (last-id (aget "id" (first (aget "items" res)))))
+    (db-vk-ensure-domain domain last-id)
+    domain))
+
+(defun %vk-find-best-photo (attach)
+  (when attach
+    (let* ((photo (aget "photo" attach))
+           (sizes (loop for (k . v) in photo
+                    when (equal "photo_" (subseq k 0 (min 6 (length k))))
+                    collect (cons (parse-integer (subseq k 6)) v))))
+      (cdr (assoc (apply #'max (mapcar #'car sizes)) sizes)))))
+
+(defun %vk-find-video (attach)
+  (when attach
+    (let ((video (aget "video" attach)))
+      (format nil "https://vk.com/video~A_~A"
+              (aget "owner_id" video)
+              (aget "id" video)))))
+
+(defun %vk-find-preview (attachments)
+  (labels ((find-type (type)
+             (find type attachments :key #'(lambda (a) (aget "type" a)) :test #'equal)))
+    (or
+     (%vk-find-best-photo (find-type "photo"))
+     (%vk-find-video (find-type "video")))))
+
+(defparameter +vk-link-scanner+ (cl-ppcre:create-scanner "\\[((id|club)\\d+)\\|([^\\]]*?)\\]") "vk linking regex")
+
+(defun %vk-post-text (post)
+  (let* ((history (aget "copy_history" post))
+         (reposts (loop for p in history
+                     collect (let* ((owner (aget "owner_id" p))
+                                    (type (if (> owner 0) "id" "club"))
+                                    (id (abs owner)))
+                               (format nil "[~A](https://vk.com/~A~A)"
+                                       (vk-get-name owner) type id)))))
+    (when history
+      (setf post (car (last history))))
+    (values
+     (cl-ppcre:regex-replace-all +vk-link-scanner+
+                                 (aget "text" post)
+                                 "[\\3](https://vk.com/\\1)")
+     (%vk-find-preview (aget "attachments" post))
+     reposts)))
+
+(defun %format-wall-post (domain name post)
+  (multiple-value-bind (text preview reposts) (%vk-post-text post)
+    (values
+     (format nil "~@[[✅](~A) ~][~A](https://vk.com/~A?w=wall~A_~A)~@[ ~{↩ ~A~}~]~@[ @ ~A~]~%~A"
+             preview name domain (aget "from_id" post) (aget "id" post)
+             reposts (format-ts (local-time:unix-to-timestamp (aget "date" post)))
+             text)
+     (if preview 0 1))))
+
+(def-message-cmd-handler handler-cmd-wall (:wall)
+  (let ((domains (db-vk-get-chat-domains chat-id)))
+    (if (null args)
+        (%send-domains chat-id domains)
+        (progn
+          (dolist (url args)
+            (handler-case
+                (let ((idx (parse-integer url)))
+                  (db-vk-remove-chat-domain chat-id (nth (1- idx) domains)))
+              (parse-error ()
+                (let* ((domain (%ensure-domain (%find-vk-domain url)))
+                       (existing (find domain domains :test #'equal)))
+                  (if existing
+                      (db-vk-remove-chat-domain chat-id domain)
+                      (db-vk-add-chat-domain chat-id domain))))
+              (error (e) (log:error "~A" e))))
+          (%send-domains chat-id (db-vk-get-chat-domains chat-id))))))