diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f563a54..003d511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ "matrix": { "lisp": [ "sbcl-bin", - "ccl-bin/1.12.0" + "ccl-bin" ] } }, @@ -35,39 +35,12 @@ "name": "Checkout Code", "uses": "actions/checkout@v3" }, - { - "name": "Grant All Perms to Make Cache Restoring Possible", - "run": "sudo mkdir -p /usr/local/etc/roswell\n sudo chown \"${USER}\" /usr/local/etc/roswell\n # Here the ros binary will be restored:\n sudo chown \"${USER}\" /usr/local/bin", - "shell": "bash" - }, - { - "name": "Get Current Month", - "id": "current-month", - "run": "echo \"value=$(date -u \"+%Y-%m\")\" >> $GITHUB_OUTPUT", - "shell": "bash" - }, - { - "name": "Cache Roswell Setup", - "id": "cache", - "uses": "actions/cache@v3", - "with": { - "path": "qlfile\nqlfile.lock\n~/.cache/common-lisp/\n~/.roswell\n/usr/local/etc/roswell\n/usr/local/bin/ros\n/usr/local/Cellar/roswell\n.qlot", - "key": "a-${{ steps.current-month.outputs.value }}-${{ env.cache-name }}-ubuntu-latest-quicklisp-${{ matrix.lisp }}-${{ hashFiles('qlfile.lock', '*.asd') }}" - } - }, - { - "name": "Restore Path To Cached Files", - "run": "echo $HOME/.roswell/bin >> $GITHUB_PATH\n echo .qlot/bin >> $GITHUB_PATH", - "shell": "bash", - "if": "steps.cache.outputs.cache-hit == 'true'" - }, { "name": "Setup Common Lisp Environment", - "uses": "40ants/setup-lisp@v2", + "uses": "40ants/setup-lisp@v3", "with": { "asdf-system": "cl-telegram-bot" - }, - "if": "steps.cache.outputs.cache-hit != 'true'" + } }, { "name": "Run Tests", diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fc99f54..27e3ce7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -54,7 +54,7 @@ }, { "name": "Setup Common Lisp Environment", - "uses": "40ants/setup-lisp@v2", + "uses": "40ants/setup-lisp@v3", "with": { "asdf-system": "cl-telegram-bot-docs" }, diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index c692522..62cb6a0 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -54,7 +54,7 @@ }, { "name": "Setup Common Lisp Environment", - "uses": "40ants/setup-lisp@v2", + "uses": "40ants/setup-lisp@v3", "with": { "asdf-system": "cl-telegram-bot" }, diff --git a/.gitignore b/.gitignore index 56661ca..df8e6dd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .#* .*.~undo-tree~ .DS_Store +*.fasl diff --git a/docs/changelog.lisp b/docs/changelog.lisp index 8473bd5..d267e92 100644 --- a/docs/changelog.lisp +++ b/docs/changelog.lisp @@ -9,7 +9,35 @@ "ASDF" "API" "REPL" + "CL-TELEGRAM-BOT/MESSAGE:REPLY" "HTTP")) + (0.5.0 2024-02-18 + " +Added +===== + +* Now bot can be started in debug mode. When this mode is on, then interactive debugger will pop up on errors. +* If bot defines some commands implementing CL-TELEGRAM-BOT/ENTITIES/COMMAND:ON-COMMAND generic-function, then + these commands will be reported to the telegram server automatically and it will show the to user when he + starts text with `/`. +* Added support for buttons with callbacks. To define a callback, implement a method for + CL-TELEGRAM-BOT/CALLBACK:ON-CALLBACK generic-function. After that, you can construct an inline keyboard + using CL-TELEGRAM-BOT/INLINE-KEYBOARD:INLINE-KEYBOARD function and CL-TELEGRAM-BOT/INLINE-KEYBOARD:CALLBACK-BUTTON function. + This keyboard object can be supplied as :REPLY-MARKUP argument to CL-TELEGRAM-BOT/RESPONSE:REPLY function. +* New functions CL-TELEGRAM-BOT/RESPONSE:ALERT and CL-TELEGRAM-BOT/RESPONSE:NOTIFY were added. An example usage of these functions + along with inline keyboard was added to `example/bot.lisp`. +* Function CL-TELEGRAM-BOT/RESPONSE-PROCESSING:INTERRUPT-PROCESSING was added in case if you want to interrupt processing of + current message and skip the rest of the handler. +* Function CL-TELEGRAM-BOT/MESSAGE:GET-CURRENT-MESSAGE was added. +* Function CL-TELEGRAM-BOT/MESSAGE:GET-CURRENT-CHAT was added. + +Removed +======= + +* Function CL-TELEGRAM-BOT/MESSAGE:REPLY was removed and replaced with CL-TELEGRAM-BOT/RESPONSE:REPLY function. + Previously it interrupted the processing flow and you only was able to reply once. With the new function + you can respond with different pieces, for example to show user a image and text with inline keyboard. +") (0.4.0 2023-04-22 " * Changed a lot of imports some symbols not bound to functions were removed, some readers and accessors are exported. diff --git a/example/bot.lisp b/example/bot.lisp new file mode 100644 index 0000000..26e8857 --- /dev/null +++ b/example/bot.lisp @@ -0,0 +1,64 @@ +(uiop:define-package #:example-bot/bot + (:use #:cl) + (:import-from #:cl-telegram-bot + #:start-processing + #:on-message + #:defbot) + (:import-from #:cl-telegram-bot/chat + #:private-chat + #:get-username) + (:import-from #:cl-telegram-bot/message + #:get-current-chat) + (:import-from #:serapeum + #:dict + #:fmt) + (:import-from #:cl-telegram-bot/response + #:alert + #:notify + #:reply) + (:import-from #:cl-telegram-bot/inline-keyboard + #:callback-button + #:inline-keyboard)) +(in-package #:example-bot/bot) + + +(defbot example-bot) + + +(defmethod on-message ((bot example-bot) + text) + (let* ((chat (get-current-chat)) + (username (get-username chat))) + (log:info "Talking to" username) + (let ((keyboard (when (string-equal text "show") + (inline-keyboard + (list + (callback-button "Alert" "alert") + (callback-button "Notify" "notify") + (callback-button "Text me" "text")))))) + (reply (fmt "Привет ~A!" + username) + :reply-markup keyboard)))) + + +(defmethod cl-telegram-bot/callback:on-callback ((bot example-bot) + callback) + (let ((data (cl-telegram-bot/callback:callback-data callback))) + (cond + ((string-equal data + "alert") + (cl-telegram-bot/response:alert "You pressed alert button!")) + + ((string-equal data + "notify") + (cl-telegram-bot/response:reply "Just replying with text.") + (cl-telegram-bot/response:notify "You pressed notify button!")) + (t + (cl-telegram-bot/response:reply "Just replying with text."))))) + + +(defun start (&key token) + (start-processing (make-example-bot (or token + (uiop:getenv "TELEGRAM_TOKEN") + (error "Define TELEGRAM_TOKEN env var."))) + :debug t)) diff --git a/qlfile.lock b/qlfile.lock index bba4e76..a41ffb2 100644 --- a/qlfile.lock +++ b/qlfile.lock @@ -1,11 +1,11 @@ ("quicklisp" . (:class qlot/source/dist:source-dist :initargs (:distribution "http://beta.quicklisp.org/dist/quicklisp.txt" :%version :latest) - :version "2023-02-15")) + :version "2023-10-21")) ("ultralisp" . (:class qlot/source/dist:source-dist :initargs (:distribution "http://dist.ultralisp.org/" :%version :latest) - :version "20230422181501")) + :version "20240218111502")) ("slynk" . (:class qlot/source/github:source-github :initargs (:repos "svetlyak40wt/sly" :ref nil :branch "patches" :tag nil) diff --git a/src/bot.lisp b/src/bot.lisp index 55f9ea6..10da619 100644 --- a/src/bot.lisp +++ b/src/bot.lisp @@ -3,13 +3,15 @@ (:import-from #:log4cl) (:import-from #:dexador) (:import-from #:jonathan) - (:export #:bot - #:get-last-update-id + (:export #:api-uri + #:bot + #:debug-mode #:defbot - #:token #:file-endpoint - #:api-uri - #:get-endpoint)) + #:get-endpoint + #:get-last-update-id + #:token + #:sent-commands-cache)) (in-package cl-telegram-bot/bot) @@ -36,7 +38,18 @@ :initarg :file-endpoint :accessor file-endpoint :documentation "HTTPS file-endpoint" - :initform nil))) + :initform nil) + (debug-mode + :initform nil + :initarg :debug-mode + :accessor debug-mode + :documentation "When debug mode is T, then interactive debugger will be called on each error.") + (sent-commands-cache :initform nil + :documentation "Command processing code will use this cache to update commands list on the server + when a new method for CL-TELEGRAM-BOT/ENTITIES/COMMAND:ON-COMMAND generic-function is defined. + + This slot is for internal use." + :accessor sent-commands-cache))) (defmacro defbot (name) diff --git a/src/callback.lisp b/src/callback.lisp new file mode 100644 index 0000000..0dfcba5 --- /dev/null +++ b/src/callback.lisp @@ -0,0 +1,80 @@ +(uiop:define-package #:cl-telegram-bot/callback + (:use #:cl) + (:import-from #:cl-telegram-bot/message + #:message + #:get-chat + #:*current-message* + #:get-rest-args + #:get-text + #:*current-bot* + #:send-message) + (:import-from #:cl-telegram-bot/pipeline + #:process) + (:import-from #:cl-telegram-bot/chat + #:get-chat-id) + (:import-from #:cl-telegram-bot/response-processing + #:process-response) + (:export #:callback-data + #:callback + #:make-callback + #:on-callback + #:callback-id + #:callback-message + #:callback-chat)) +(in-package #:cl-telegram-bot/callback) + + +(defclass callback () + ((id :initarg :id + :type string + :reader callback-id) + (data :initarg :data + :type string + :reader callback-data) + (message :initarg :message + :type message + :reader callback-message))) + + +(defgeneric on-callback (bot callback) + (:documentation "Called when user clicks callback button. Second argument is an object of CALLBACK type.") + (:method ((bot t) (callback t)) + ;; Doing nothing + (values))) + + +(defgeneric make-callback (bot callback-data) + (:documentation "Called when user clicks callback button. Should return an instance of CALLBACK class. + + Application may override this method to return objects of different callback classes depending on + callback-data string. This way it mab be easier to define more specific methods for + ON-CALLBACK generic-function.") + (:method ((bot t) (callback-data t)) + (let ((id (getf callback-data :|id|)) + (data (getf callback-data :|data|)) + (message-data (getf callback-data :|message|))) + (make-instance 'callback + :id id + :data data + :message (cl-telegram-bot/message:make-message message-data))))) + + +(defmethod process ((bot t) (callback callback)) + "" + (log:debug "Processing callback" callback) + + (let ((*current-bot* bot) + (*current-message* callback)) + (handler-case + (on-callback bot callback) + (cl-telegram-bot/response-processing:interrupt-processing (condition) + (declare (ignore condition)) + (log:debug "Interrupting callback processing" callback)))) + (values)) + + +(defgeneric callback-chat (callback) + (:documentation "Returns a chat from where callback was sent.") + + (:method ((callback callback)) + (cl-telegram-bot/message:get-chat (callback-message callback)))) diff --git a/src/ci.lisp b/src/ci.lisp index 10f4a55..d660e2a 100644 --- a/src/ci.lisp +++ b/src/ci.lisp @@ -32,11 +32,9 @@ :on-push-to "clos-everywhere" :by-cron "0 10 * * 1" :on-pull-request t - :cache t + ;; :cache t :jobs ((run-tests :asdf-system "cl-telegram-bot" :lisp ("sbcl-bin" - ;; Issue https://github.com/roswell/roswell/issues/534 - ;; is still reproduces on 2023-02-06: - "ccl-bin/1.12.0") + "ccl-bin") :coverage t))) diff --git a/src/commands.lisp b/src/commands.lisp new file mode 100644 index 0000000..5223e8f --- /dev/null +++ b/src/commands.lisp @@ -0,0 +1,44 @@ +(uiop:define-package #:cl-telegram-bot/commands + (:use #:cl) + (:import-from #:log4cl) + (:import-from #:cl-telegram-bot/chat + #:get-chat-id + #:make-chat + #:chat) + (:import-from #:cl-telegram-bot/entities/core + #:make-entity) + (:import-from #:cl-telegram-bot/network + #:make-request) + (:import-from #:cl-telegram-bot/pipeline + #:process) + (:import-from #:cl-telegram-bot/bot + #:bot) + (:import-from #:serapeum + #:soft-alist-of + #:defvar-unbound) + (:import-from #:cl-telegram-bot/utils + #:def-telegram-call) + (:import-from #:cl-telegram-bot/response-processing + #:process-response + #:interrupt-processing) + (:export)) +(in-package #:cl-telegram-bot/commands) + + +(declaim (ftype (function (bot (or (soft-alist-of string string) + (serapeum:soft-list-of string))) + (values)) + set-my-commands)) + +;; TODO: Support scope and language optional arguments +(defun set-my-commands (bot commands) + "https://core.telegram.org/bots/api#setmycommands" + (log:debug "Sending commands to the server" commands) + (make-request bot "setMyCommands" + :|commands| (loop for command in commands + collect (etypecase command + (string (list :|command| command + :|description| "No documentation.")) + (cons (list :|command| (car command) + :|description| (cdr command)))))) + (values)) diff --git a/src/core.lisp b/src/core.lisp index 23a3ba1..5791f0d 100644 --- a/src/core.lisp +++ b/src/core.lisp @@ -1,4 +1,4 @@ -(defpackage #:cl-telegram-bot/core +(uiop:define-package #:cl-telegram-bot/core (:use #:cl) (:nicknames #:cl-telegram-bot) (:import-from #:bordeaux-threads @@ -7,12 +7,15 @@ (:import-from #:log4cl) (:import-from #:cl-telegram-bot/update #:process-updates) + (:import-from #:cl-telegram-bot/response + #:reply) (:import-from #:cl-telegram-bot/bot + #:debug-mode #:defbot) (:import-from #:cl-telegram-bot/message - #:on-message - #:reply) + #:on-message) (:import-from #:cl-telegram-bot/entities/command + #:update-commands #:on-command) (:import-from #:trivial-backtrace #:print-backtrace) @@ -31,10 +34,14 @@ (defvar *threads* nil) -(defun start-processing (bot &key debug (delay-between-retries 10)) +(defun start-processing (bot &key debug + (delay-between-retries 10) + (thread-name "telegram-bot")) (when (getf *threads* bot) (error "Processing already started.")) + (setf (debug-mode bot) debug) + (log:info "Starting thread to process updates for" bot) (flet ((continue-processing-if-not-debug (condition) (let ((restart (find-restart 'cl-telegram-bot/update::continue-processing @@ -44,14 +51,22 @@ condition :output nil))) (log:error "Unable to process Telegram updates" traceback)) - (unless debug - (invoke-restart restart delay-between-retries)))))) + (unless (debug-mode bot) + (invoke-restart restart delay-between-retries))))) + (stop-bot () + (stop-processing bot))) + + (update-commands bot) + (setf (getf *threads* bot) (make-thread (lambda () (handler-bind ((error #'continue-processing-if-not-debug)) (process-updates bot))) - :name "telegram-bot")))) + :name thread-name)) + + ;; Here we return a closure to stop the bot: + #'stop-bot)) (defun stop-processing (bot) diff --git a/src/entities/command.lisp b/src/entities/command.lisp index b357445..c0a3157 100644 --- a/src/entities/command.lisp +++ b/src/entities/command.lisp @@ -1,4 +1,4 @@ -(defpackage #:cl-telegram-bot/entities/command +(uiop:define-package #:cl-telegram-bot/entities/command (:use #:cl) (:import-from #:log4cl) (:import-from #:cl-telegram-bot/entities/core @@ -11,12 +11,26 @@ #:make-keyword) (:import-from #:cl-telegram-bot/pipeline #:process) - (:export - #:get-command - #:bot-command - #:get-rest-text - #:on-command)) -(in-package cl-telegram-bot/entities/command) + (:import-from #:cl-telegram-bot/bot + #:bot + #:sent-commands-cache) + (:import-from #:alexandria + #:assoc-value) + (:import-from #:serapeum + #:soft-alist-of + #:soft-list-of) + (:import-from #:closer-mop + #:generic-function-methods + #:method-specializers) + (:import-from #:cl-telegram-bot/commands + #:set-my-commands) + (:import-from #:str + #:replace-all) + (:export #:get-command + #:bot-command + #:get-rest-text + #:on-command)) +(in-package #:cl-telegram-bot/entities/command) (defclass bot-command (entity) @@ -58,7 +72,64 @@ (log:debug "Command was called" command rest-text)) +(declaim (ftype (function (bot) (soft-list-of closer-mop:method)) + bot-methods)) + +(defun bot-methods (bot) + (loop for method in (generic-function-methods #'on-command) + for specializers = (method-specializers method) + when (eql (first specializers) + (class-of bot)) + collect method)) + + +(declaim (ftype (function (bot) + (soft-alist-of string string)) + bot-commands)) + +(defun bot-commands (bot) + (loop for method in (bot-methods bot) + for specializers = (closer-mop:method-specializers method) + for specializer = (second specializers) + when (typep specializer 'closer-mop:eql-specializer) + collect (cons (replace-all "-" "_" + (string-downcase + (closer-common-lisp:eql-specializer-object specializer))) + (or (documentation method t) + "No documentation.")))) + + +(declaim (ftype (function (bot &key (:command-name-to-check (or null + string))) + (soft-alist-of string string)) + update-commands)) + +(defun update-commands (bot &key command-name-to-check) + (let ((commands (bot-commands bot))) + ;; We don't want to send commands each time when user + ;; enters /blah-something to prevent DoS attacks. + ;; That is why we update commands list on the server + ;; only if command is known: + (when (or (null command-name-to-check) + (assoc-value (sent-commands-cache bot) + command-name-to-check + :test #'string-equal)) + (set-my-commands bot commands)) + + (values commands))) + + (defmethod process ((bot t) (command bot-command)) - (on-command bot - (get-command command) - (get-rest-text command))) + (let* ((command-name (get-command command)) + (command-str-name (str:replace-all "-" "_" + (string-downcase command-name)))) + (unless (assoc-value (sent-commands-cache bot) + command-str-name + :test #'string-equal) + (setf (sent-commands-cache bot) + (update-commands bot + :command-name-to-check command-str-name))) + + (on-command bot + command-name + (get-rest-text command)))) diff --git a/src/inline-keyboard.lisp b/src/inline-keyboard.lisp index 6d104c2..fb462b1 100644 --- a/src/inline-keyboard.lisp +++ b/src/inline-keyboard.lisp @@ -1,16 +1,101 @@ -(defpackage #:cl-telegram-bot/inline-keyboard - (:use #:cl)) +(uiop:define-package #:cl-telegram-bot/inline-keyboard + (:use #:cl) + (:import-from #:cl-telegram-bot/network + #:make-request) + (:import-from #:cl-telegram-bot/callback + #:callback) + (:import-from #:cl-telegram-bot/markup + #:to-markup) + (:import-from #:serapeum + #:dict) + (:export #:answer-callback-query + #:inline-keyboard + #:keyboard-rows + #:button-text + #:inline-keyboard-button + #:callback-button + #:url-button + #:callback-button-data + #:button-url)) (in-package cl-telegram-bot/inline-keyboard) -;; TODO: refactor -(defun answer-callback-query (b callback-query-id &key text show-alert url) +(defclass inline-keyboard () + ((rows :initarg :rows + :type list + :initform nil + :reader keyboard-rows)) + (:documentation "Represents an inline keyboard as specified in API https://core.telegram.org/bots/api#inlinekeyboardmarkup.")) + + +(defclass inline-keyboard-button () + ((text :initarg :text + :type string + :reader button-text)) + (:documentation "Base class for all inline keyboard buttons. + + API: https://core.telegram.org/bots/api#inlinekeyboardbutton")) + + +(defclass callback-button (inline-keyboard-button) + ((data :initarg :data + :type string + :reader callback-button-data))) + + +(defclass url-button (inline-keyboard-button) + ((url :initarg :data + :type string + :reader button-url))) + + +(defun inline-keyboard (rows) + "Returns an inline keyboard which can be passed + to CL-TELEGRAM-BOT/RESPONSE:REPLY as REPLY-MARKUP argument. + + Each row should be a list of INLINE-KEYBOARD-BUTTON objects or a single + object of this class. In latter case, such row will have only one button." + (make-instance 'inline-keyboard + :rows (mapcar #'uiop:ensure-list rows))) + + +(defun callback-button (text data) + "Creates a button which will call a callback." + (make-instance 'callback-button :text text + :data data)) + +(defun url-button (text url) + "Creates a button which will open an url." + (make-instance 'url-button :text text + :url url)) + + +(defun answer-callback-query (bot callback &key text show-alert url) "https://core.telegram.org/bots/api#answercallbackquery" + (check-type callback callback) (let ((options - (list - (cons :callback_query_id callback-query-id)))) - (when text (nconc options `((:text . ,text)))) - (when show-alert (nconc options `((:show_alert . ,show-alert)))) - (when url (nconc options `((:url . ,url)))) - (apply #'make-request b "answerCallbackQuery" options))) + (append + (list + :callback_query_id (cl-telegram-bot/callback:callback-id callback)) + (when text + (list :text text)) + (when show-alert + (list :show_alert show-alert)) + (when url + (list :url url))))) + (apply #'make-request bot "answerCallbackQuery" options))) + + +(defmethod to-markup ((keyboard inline-keyboard)) + (dict "inline_keyboard" + (loop for row in (keyboard-rows keyboard) + collect (mapcar #'to-markup row)))) + +(defmethod to-markup ((button callback-button)) + (dict "text" (button-text button) + "callback_data" (callback-button-data button))) + +(defmethod to-markup ((button url-button)) + (dict "text" (button-text button) + "url" (button-url button))) diff --git a/src/inline.lisp b/src/inline.lisp index 2e1ea9a..130c1c5 100644 --- a/src/inline.lisp +++ b/src/inline.lisp @@ -4,11 +4,12 @@ (defun answer-inline-query (b inline-query-id results &key cache-time is-personal next-offset switch-pm-text) - "https://core.telegram.org/bots/api#answerinlinequery" + "https://core.telegram.org/bots/api#answerinlinequery + https://core.telegram.org/bots/inline" (let ((options - (list - (cons :inline_query_id inline-query-id) - (cons :results results)))) + (list + (cons :inline_query_id inline-query-id) + (cons :results results)))) (when cache-time (nconc options `((:cache_time . ,cache-time)))) (when is-personal (nconc options `((:is_personal . ,is-personal)))) (when next-offset (nconc options `((:next_offset . ,next-offset)))) diff --git a/src/keyboard.lisp b/src/keyboard.lisp new file mode 100644 index 0000000..0f4f8ee --- /dev/null +++ b/src/keyboard.lisp @@ -0,0 +1,98 @@ +(uiop:define-package #:cl-telegram-bot/keyboard + (:use #:cl) + (:import-from #:cl-telegram-bot/markup + #:to-markup) + (:import-from #:serapeum + #:dict) + (:export #:keyboard + #:keyboard-rows + #:button-text + #:button)) +(in-package #:cl-telegram-bot/keyboard) + + +(defclass keyboard () + ((rows :initarg :rows + :type list + :initform nil + :reader keyboard-rows) + (persistent :initarg :persistent + :type boolean + :initform nil + :reader persistentp + :documentation "Requests clients to always show the keyboard when the regular keyboard is hidden.") + (resize :initarg :resize + :type boolean + :initform nil + :reader resizep + :documentation "Requests clients to resize the keyboard vertically for optimal fit (e.g., make the keyboard smaller if there are just two rows of buttons).") + (one-time :initarg :one-time + :type boolean + :initform nil + :reader one-time-p + :documentation "Requests clients to hide the keyboard as soon as it's been used.") + (selective :initarg :selective + :type boolean + :initform nil + :reader selectivep + :documentation "Use this parameter if you want to show the keyboard to specific users only.") + (input-field-placeholder :initarg :input-field-placeholder + :initform nil + :reader input-field-placeholder + :documentation "The placeholder to be shown in the input field when the keyboard is active.")) + (:documentation "Represents a keyboard specified in API https://core.telegram.org/bots/api#replykeyboardmarkup.")) + + +(defclass button () + ((text :initarg :text + :type string + :reader button-text)) + (:documentation "Base class for all inline keyboard buttons. + + API: https://core.telegram.org/bots/api#keyboardbutton")) + + +(defun keyboard (rows &rest args &key peristent resize one-time selective input-field-placeholder) + "Returns a keyboard which can be passed + to CL-TELEGRAM-BOT/RESPONSE:REPLY as REPLY-MARKUP argument. + + Each row should be a list of BUTTON objects or a single + object of this class. In latter case, such row will have only one button." + (declare (ignore peristent resize one-time selective input-field-placeholder)) + (apply #'make-instance + 'keyboard + :rows (mapcar #'uiop:ensure-list rows) + args)) + + +(defun button (text) + (make-instance 'button + :text text)) + + +(defmethod to-markup ((keyboard keyboard)) + (let ((result (dict "keyboard" + (loop for row in (keyboard-rows keyboard) + collect (mapcar #'to-markup row))))) + (when (persistentp keyboard) + (setf (gethash "is_persistent" result) t)) + + (when (resizep keyboard) + (setf (gethash "resize_keyboard" result) t)) + + (when (one-time-p keyboard) + (setf (gethash "one_time_keyboard" result) t)) + + (when (selectivep keyboard) + (setf (gethash "selective" result) t)) + + (when (input-field-placeholder keyboard) + (setf (gethash "input_field_placeholder" result) + (input-field-placeholder keyboard))) + + (values result))) + + +(defmethod to-markup ((button button)) + (dict "text" (button-text button))) + diff --git a/src/markup.lisp b/src/markup.lisp new file mode 100644 index 0000000..70d9769 --- /dev/null +++ b/src/markup.lisp @@ -0,0 +1,13 @@ +(uiop:define-package #:cl-telegram-bot/markup + (:use #:cl) + (:export #:to-markup)) +(in-package #:cl-telegram-bot/markup) + + +(defgeneric to-markup (obj) + (:documentation "Transforms object into markup of Telegram API. + + Methods of this class should return a hash-table, representing OBJ + in terms of Telegram API.") + (:method ((obj hash-table)) + obj)) diff --git a/src/message.lisp b/src/message.lisp index 6d02471..db9e930 100644 --- a/src/message.lisp +++ b/src/message.lisp @@ -17,70 +17,74 @@ #:defvar-unbound) (:import-from #:cl-telegram-bot/utils #:def-telegram-call) - (:export #:send-message - #:send-photo - #:send-audio - #:send-document - #:send-video - #:send-animation - #:send-video-note - #:send-voice - #:send-sticker + (:import-from #:cl-telegram-bot/response-processing + #:process-response + #:interrupt-processing) + (:export #:animation + #:animation-message + #:audio + #:audio-message #:delete-message + #:document + #:document-message + #:file + #:file-message #:forward-message - #:make-message - #:get-text #:get-caption - #:get-raw-data #:get-chat + #:get-current-chat + #:get-current-message + #:get-duration + #:get-emoji #:get-entities + #:get-file + #:get-file-id + #:get-file-name + #:get-file-size + #:get-file-unique-id #:get-forward-from #:get-forward-from-chat #:get-forward-sender-name - #:message - #:get-reply-to-message - #:reply - #:get-duration - #:get-length - #:get-width #:get-height - #:get-file-id - #:get-file-unique-id - #:get-file-name - #:get-file-size - #:get-mime-type - #:on-message - #:get-current-chat - #:get-performer - #:get-title #:get-is-animated #:get-is-video - #:get-emoji - #:get-set-name - #:get-file - #:file-message - #:file - #:animation-message - #:animation - #:audio-message - #:audio + #:get-length + #:get-message-id + #:get-mime-type + #:get-performer #:get-photo-options - #:photo-message + #:get-raw-data + #:get-reply-to-message + #:get-set-name + #:get-text + #:get-title + #:get-width + #:make-message + #:message + #:on-message #:photo - #:document-message - #:document - #:video-message + #:photo-message + #:reply + #:send-animation + #:send-audio + #:send-document + #:send-message + #:send-photo + #:send-sticker + #:send-video + #:send-video-note + #:send-voice + #:spatial + #:sticker + #:sticker-message + #:temporal + #:unispatial #:video - #:video-note-message + #:video-message #:video-note - #:voice-message + #:video-note-message #:voice - #:sticker-message - #:sticker - #:unispatial - #:spatial - #:temporal - #:get-message-id)) + #:voice-message)) (in-package cl-telegram-bot/message) @@ -288,15 +292,18 @@ the file.") (defclass voice-message (file-message) ()) + (defclass reply (message) ((reply-to-message :initarg :reply-to-message :reader get-reply-to-message))) + (defmethod initialize-instance :after ((reply reply) &key data &allow-other-keys) (when data (setf (slot-value reply 'reply-to-message) (make-message (getf data :|reply_to_message|))))) + (defun make-message (data) (when data (destructuring-bind (class &optional file-attribute-name file-class) @@ -338,7 +345,10 @@ the file.") reply-to-message-id reply-markup)) (log:debug "Sending message" chat text) (apply #'make-request bot "sendMessage" - :|chat_id| (get-chat-id chat) + :|chat_id| (typecase chat + (string chat) + (t + (get-chat-id chat))) :|text| text options)) @@ -749,38 +759,6 @@ https://core.telegram.org/bots/api#sendsticker" :|message_id| (get-message-id message))) -(define-condition reply-immediately () - ((text :initarg :text - :reader get-text) - (args :initarg :args - :reader get-rest-args))) - - -(defun reply (text - &rest args - &key - parse-mode - disable-web-page-preview - disable-notification - reply-to-message-id - reply-markup) - (declare (ignorable parse-mode - disable-web-page-preview - disable-notification - reply-to-message-id - reply-markup)) - "Works like a send-message, but only when an incoming message is processed. - Automatically sends reply to a chat from where current message came from." - (unless (and (boundp '*current-bot*) - (boundp '*current-message*)) - (error "Seems (reply ~S) was called outside of processing pipeline, because no current message is available." - text)) - - (signal 'reply-immediately - :text text - :args args)) - - (defgeneric on-message (bot text) (:documentation "This method gets called with raw text from the message. By default it does nothing.")) @@ -811,16 +789,19 @@ https://core.telegram.org/bots/api#sendsticker" (on-message bot (get-text message))) - (reply-immediately (condition) - (log:debug "Replying to" *current-message*) - (apply #'send-message - *current-bot* - (get-chat *current-message*) - (get-text condition) - (get-rest-args condition))))) + (interrupt-processing (condition) + (declare (ignore condition)) + (log:debug "Interrupting processing of message")))) (values)) +(defun get-current-message () + "Returns currently processed message." + (unless (boundp '*current-message*) + (error "Seems (get-current-message) was called outside of processing pipeline, because no current message is available.")) + (values *current-message*)) + + (defun get-current-chat () "Returns a chat where currently processing message was received." (unless (boundp '*current-message*) diff --git a/src/network.lisp b/src/network.lisp index a5bbcde..a9ca8dc 100644 --- a/src/network.lisp +++ b/src/network.lisp @@ -1,5 +1,7 @@ (defpackage #:cl-telegram-bot/network (:use #:cl) + (:import-from #:alexandria) + (:import-from #:dexador) (:import-from #:log4cl) (:import-from #:cl-telegram-bot/utils #:obfuscate) @@ -25,9 +27,8 @@ (defun make-request (bot name &rest options &key (streamp nil) (timeout 3) &allow-other-keys) - "Perform HTTP request to 'name API method with 'options JSON-encoded object." (declare (ignore streamp)) - + "Perform HTTP request to 'name API method with 'options JSON-encoded object." (let ((url (concatenate 'string (get-endpoint bot) name))) (log:debug "Posting data to" (obfuscate url) diff --git a/src/response-processing.lisp b/src/response-processing.lisp new file mode 100644 index 0000000..ce54d6a --- /dev/null +++ b/src/response-processing.lisp @@ -0,0 +1,17 @@ +(uiop:define-package #:cl-telegram-bot/response-processing + (:use #:cl) + (:export #:process-response + #:interrupt-processing)) +(in-package #:cl-telegram-bot/response-processing) + + +(define-condition interrupt-processing () + ()) + + +(defgeneric process-response (bot message response) + (:documentation "Processes immediate responses of different types.")) + + +(defun interrupt-processing () + (signal 'interrupt-processing)) diff --git a/src/response.lisp b/src/response.lisp new file mode 100644 index 0000000..7c19f73 --- /dev/null +++ b/src/response.lisp @@ -0,0 +1,175 @@ +(uiop:define-package #:cl-telegram-bot/response + (:use #:cl) + (:import-from #:cl-telegram-bot/message + #:message + #:get-chat + #:send-message + #:*current-message* + #:*current-bot*) + (:import-from #:cl-telegram-bot/response-processing + #:interrupt-processing + #:process-response) + (:import-from #:cl-telegram-bot/callback + #:callback-message + #:callback) + (:import-from #:cl-telegram-bot/inline-keyboard + #:answer-callback-query) + (:import-from #:cl-telegram-bot/markup + #:to-markup) + (:export #:response-text + #:reply + #:notify + #:open-url + #:alert + #:response + #:url-to-open + #:rest-args + #:response-with-text)) +(in-package #:cl-telegram-bot/response) + + +(defclass response () + ((args :initarg :args + :type list + :reader rest-args))) + + +(defclass response-with-text (response) + ((text :initarg :text + :reader response-text))) + + +(defclass reply (response-with-text) + ()) + + +(defclass notify (response-with-text) + ()) + + +(defclass alert (response-with-text) + ()) + + +(defclass open-url (response) + ((url :initarg :text + :type string + :reader url-to-open))) + + + +(defun reply (text + &rest args + &key + ;; Set this to "markdown" to allow rich formatting + ;; https://core.telegram.org/bots/api#formatting-options + parse-mode + disable-web-page-preview + disable-notification + reply-to-message-id + reply-markup + (immediately t)) + (declare (ignorable parse-mode + disable-web-page-preview + disable-notification + reply-to-message-id)) + "Works like a SEND-MESSAGE, but only when an incoming message is processed. + Automatically sends reply to a chat from where current message came from." + (unless (and (boundp '*current-bot*) + (boundp '*current-message*)) + (error "Seems (reply ~S) was called outside of processing pipeline, because no current message is available." + text)) + + (when reply-markup + (setf (getf args :reply-markup) + (to-markup reply-markup))) + + (process-response *current-bot* + *current-message* + (make-instance 'reply + :text text + :args args)) + (when immediately + (interrupt-processing))) + + +(defun notify (text) + "Works like a SEND-MESSAGE, but only when an incoming message is processed. + Automatically sends reply to a chat from where current message came from." + (unless (and (boundp '*current-bot*) + (boundp '*current-message*)) + (error "Seems (notify ~S) was called outside of processing pipeline, because no current message is available." + text)) + + (process-response *current-bot* + *current-message* + (make-instance 'notify + :text text))) + + +(defun alert (text) + "Works like a SEND-MESSAGE, but only when an incoming message is processed. + Automatically sends reply to a chat from where current message came from." + (unless (and (boundp '*current-bot*) + (boundp '*current-message*)) + (error "Seems (alert ~S) was called outside of processing pipeline, because no current message is available." + text)) + + (process-response *current-bot* + *current-message* + (make-instance 'alert + :text text))) + + +(defun open-url (url) + "Works like a SEND-MESSAGE, but only when an incoming message is processed. + Automatically sends reply to a chat from where current message came from." + (unless (and (boundp '*current-bot*) + (boundp '*current-message*)) + (error "Seems (open-url ~S) was called outside of processing pipeline, because no current message is available." + url)) + + (process-response *current-bot* + *current-message* + (make-instance 'open-url + :url url))) + + +(defmethod process-response ((bot t) (message message) (response reply)) + (apply #'send-message + bot + (get-chat message) + (response-text response) + (rest-args response))) + + +(defmethod process-response ((bot t) (callback callback) (response reply)) + (apply #'send-message + bot + (get-chat (callback-message callback)) + (response-text response) + (rest-args response)) + ;; And we need to send empty callback answer, just to hide loading process bar. + (answer-callback-query bot + callback)) + + +(defmethod process-response ((bot t) (message callback) (response notify)) + (answer-callback-query bot + message + :text (response-text response))) + + +(defmethod process-response ((bot t) (message callback) (response alert)) + (answer-callback-query bot + message + :text (response-text response) + :show-alert t)) + + +(defmethod process-response ((bot t) (message callback) (response open-url)) + (answer-callback-query bot + message + :url (url-to-open response))) + + diff --git a/src/update.lisp b/src/update.lisp index 0173837..86b0348 100644 --- a/src/update.lisp +++ b/src/update.lisp @@ -2,6 +2,7 @@ (:use #:cl) (:import-from #:log4cl) (:import-from #:cl-telegram-bot/message + #:*current-bot* #:make-message) (:import-from #:cl-telegram-bot/network #:make-request) @@ -10,6 +11,8 @@ #:bot) (:import-from #:cl-telegram-bot/pipeline #:process) + (:import-from #:cl-telegram-bot/callback + #:make-callback) (:export #:make-update #:get-raw-data #:get-update-id @@ -28,19 +31,40 @@ :reader get-raw-data))) +(defclass callback-query (update) + ()) + + (defun make-update (data) - (let ((message-data (getf data :|message|))) - (if message-data - (make-instance 'update - :id (getf data :|update_id|) - :payload (make-message message-data) - :raw-data data) - (progn (log:warn "Received not supported update" - data) - (make-instance 'update - :id (getf data :|update_id|) - :payload nil - :raw-data data))))) + (cond + ((getf data :|message|) + (let ((message-data (getf data :|message|))) + (make-instance 'update + :id (getf data :|update_id|) + :payload (make-message message-data) + :raw-data data))) + ((getf data :|callback_query|) + (let* ((callback-data (getf data :|callback_query|)) + ;; (callback-id (getf query :|id|)) + ;; (callback-data (getf query :|data|)) + ;; (message-data (getf query :|message|)) + ) + (make-instance 'callback-query + :id (getf data :|update_id|) + :payload (make-callback *current-bot* + callback-data + ;; callback-id + ;; callback-data + ;; (make-message message-data) + ) + :raw-data data))) + (t + (log:warn "Received not supported update" + data) + (make-instance 'update + :id (getf data :|update_id|) + :payload nil + :raw-data data)))) (defun get-updates (bot &key limit timeout) @@ -76,7 +100,7 @@ (defmethod process-updates ((bot t)) "Starts inifinite loop to process updates using long polling." - (loop + (loop with *current-bot* = bot do (loop for update in (restart-case (get-updates bot :timeout 10)