2017-08-07 15:30:38 +01:00
;; cl-telegram-bot
;;
;; MIT License
;;
;; Copyright (c) 2016 Rei <https://github.com/sovietspaceship>
2019-08-24 11:32:31 +02:00
;; Copyright (c) 2019 Hiro98 <https://protagon.space>
2017-08-07 15:30:38 +01:00
;;
;; Permission is hereby granted, free of charge, to any person obtaining a copy
;; of this software and associated documentation files (the "Software"), to deal
;; in the Software without restriction, including without limitation the rights
;; to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
;; copies of the Software, and to permit persons to whom the Software is
;; furnished to do so, subject to the following conditions:
;;
;; The above copyright notice and this permission notice shall be included in all
;; copies or substantial portions of the Software.
;;
;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
;; IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
;; FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
;; AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
;; LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
;; OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
;; SOFTWARE.
2016-08-14 04:35:03 +02:00
2019-08-18 22:29:48 +02:00
2016-08-17 19:55:19 +02:00
( in-package :cl-telegram-bot )
2016-08-14 04:35:03 +02:00
2017-11-18 10:52:37 +02:00
( alexandria:define-constant +http-ok+ 200 :test #' = )
2019-08-18 22:29:48 +02:00
( defvar *bot* nil )
( alexandria:define-constant +return-var+ '* )
2017-07-10 20:56:04 +01:00
2019-08-18 22:29:48 +02:00
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Basic BOT Implementation ;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
2019-08-24 11:32:31 +02:00
( defclass tg-bot ( )
2016-08-14 04:35:03 +02:00
( ( id
:documentation "Update id"
2016-08-14 13:18:43 +03:00
:initform 0
:accessor id )
2016-08-14 04:35:03 +02:00
( token
:initarg :token
:documentation "Bot token given by BotFather"
2016-08-17 19:57:24 +02:00
:accessor token
2016-08-14 13:18:43 +03:00
:initform nil )
2017-07-10 20:56:04 +01:00
( api-uri
:initarg :api-uri
:initform "https://api.telegram.org/"
:accessor api-uri )
2016-08-14 04:35:03 +02:00
( endpoint
:initarg :endpoint
2016-08-14 13:18:43 +03:00
:accessor endpoint
2017-07-10 20:56:04 +01:00
:documentation "HTTPS endpoint" )
( file-endpoint
:initarg :file-endpoint
:accessor file-endpoint
:documentation "HTTPS file-endpoint"
2019-08-18 22:29:48 +02:00
:initform nil )
( update-hooks
2019-09-02 21:06:06 +02:00
:documentation " A list of functions to call after retrieving
updates by FETCH-UPDATES. "
2019-08-18 22:29:48 +02:00
:type ( proper-list function )
:initform nil )
( reply-queue
:type ( proper-list function )
2019-08-20 23:57:44 +02:00
:documentation "A queue for storing reply matchers."
2019-09-20 15:51:55 +02:00
:initform nil )
( commands
:type ( proper-list ( proper-list ) )
:documentation "A list to store commands as (command-regex callback separator)."
2019-08-24 11:32:31 +02:00
:initform nil ) )
2019-09-02 21:06:06 +02:00
( :documentation " The TG-BOT type is just a basic data container to
hold various transactional data. It does not feature polling or any
other advanced features. Only the TOKEN initarg is required. " ) )
2016-08-14 04:35:03 +02:00
2019-08-24 11:32:31 +02:00
( defmethod initialize-instance :after ( ( object tg-bot ) &key &allow-other-keys )
2017-07-10 20:56:04 +01:00
( with-accessors ( ( token token )
( endpoint endpoint )
( file-endpoint file-endpoint )
( api-uri api-uri ) ) object
2019-08-18 22:29:48 +02:00
( setf endpoint ( concatenate 'string api-uri "bot" token "/" )
file-endpoint ( concatenate 'string api-uri "file/" "bot" token "/" ) ) ) )
2019-09-20 15:51:55 +02:00
( defun make-tg-bot ( token &optional api-url )
"Create a new TG-BOT instance. Takes a TOKEN string and optionally an API-URL string."
( let ( ( args ( if api-url
` ( :token , token :api-url api-url )
` ( :token , token ) ) ) )
( apply #' make-instance ` ( tg-bot ,@ args ) ) ) )
( defgeneric decode ( object ) )
( defmethod decode ( ( object stream ) )
( let ( ( json:*json-symbols-package* :cl-telegram-bot ) )
( json:with-decoder-simple-clos-semantics
( prog1
( json:decode-json object )
( close object ) ) ) ) )
( defmethod decode ( ( object string ) )
( let ( ( json:*json-symbols-package* :cl-telegram-bot ) )
( json:with-decoder-simple-clos-semantics
( with-input-from-string ( stream object )
( json:decode-json stream ) ) ) ) )
( defmethod decode ( ( object vector ) )
( decode ( map 'string #' code-char object ) ) )
( define-condition request-error ( error )
( ( what :initarg :what :reader what ) )
( :report ( lambda ( condition stream )
( format stream "Request error: ~A" ( what condition ) ) ) ) )
( defun make-request ( b name options &key ( return-type nil ) ( timeout 10 ) )
"Perform HTTP request to NAME API method with OPTIONS JSON-encoded object."
( let* ( ( results ( multiple-value-list
( handler-bind ( ( dex:http-request-bad-request #' dex:ignore-and-continue ) )
( dex:request
( concatenate 'string ( endpoint b ) name )
:method :post
:want-stream t
:headers ' ( ( "Content-Type" . "Application/Json" ) )
:timeout timeout
:content ( json:encode-json-alist-to-string options ) ) ) ) )
( message ( nth 0 results ) ) )
( with-slots ( ok result description ) ( decode message )
( if ok
( if return-type ; wether to cast into a known custom class or not
( recursive-change-class result return-type )
result )
( error 'request-error :what description ) ) ) ) )
;;; Reply Matchers
2019-09-02 21:06:06 +02:00
( define-condition reply-matcher-timeout-error ( error )
2019-09-17 14:54:49 +02:00
( ( timeout :initarg :timeout :reader timeout ) )
( :report ( lambda ( condition stream ) ( format stream " Timed out while
waiting for a reply. Timeout was ~a. " ( timeout condition ) ) )
:documentation "Gets signalled if a reply doesn't arive in time." ) )
2019-08-18 22:29:48 +02:00
2019-09-02 21:06:06 +02:00
( defgeneric add-reply-matcher ( bot matcher result timeout )
( :documentation " Adds a reply matcher function to BOT that takes an
object of type *UPDATE and the the result of the api-call RESULT as
arguments and returns non-nil if the update is the desired
reply. The reply matcher will be removed after TIMEOUT
seconds. Returns a PROMISE that resolves to either the return value
of MATCHER. An condition is signaled on timeout. " ) )
2019-09-20 15:51:55 +02:00
( defmethod add-reply-matcher :before ( bot matcher result timeout )
( log:debug "Adding reply watcher: " ( list bot matcher result timeout ) ) )
2019-09-02 22:06:16 +02:00
( defmethod add-reply-matcher ( ( bot tg-bot ) matcher result timeout )
2019-08-18 22:29:48 +02:00
( let ( ( promise ( lparallel:promise ) ) )
2019-09-02 21:06:06 +02:00
( push ` ( , promise , matcher , result , ( when timeout ( + ( get-universal-time ) timeout ) ) )
( slot-value bot 'reply-queue ) )
2019-08-18 22:29:48 +02:00
promise ) )
2019-09-20 15:51:55 +02:00
;;; Update Hooks
2019-08-20 23:57:44 +02:00
( defgeneric add-update-hook ( bot hook &optional key )
2019-09-02 21:06:06 +02:00
( :documentation " Adds an update hook that will be called with an
*UPDATE object on each update, the return value is ignored. Returns
a keyword to remove that hook. " ) )
2019-08-20 23:57:44 +02:00
2019-09-20 15:51:55 +02:00
( defmethod add-update-hook :before ( bot hook &optional key )
( log:debug "Adding reply watcher: " ( list bot hook key ) ) )
2019-08-24 11:32:31 +02:00
( defmethod add-update-hook ( ( bot tg-bot ) hook &optional key )
2019-08-20 23:57:44 +02:00
( let ( ( final-key ( if key key ( gensym ) ) ) )
( with-slots ( update-hooks ) bot
( when ( and key ( find key update-hooks :key #' car ) )
( error ( format nil "Hook with the key \"~a\" already registered." key ) ) )
( push ` ( , final-key . , hook ) ( slot-value bot 'update-hooks ) ) )
2019-08-24 11:32:31 +02:00
final-key ) )
2019-08-20 23:57:44 +02:00
( defgeneric remove-update-hook ( bot key )
2019-09-02 21:06:06 +02:00
( :documentation " Removes an update hook by its key which was
returned open its registration. Returns t ( success ) or nil. " ) )
2019-08-20 23:57:44 +02:00
2019-09-20 15:51:55 +02:00
( defmethod remove-update-hook :before ( bot key )
( log:debug "Adding reply watcher: " ( list bot key ) ) )
2019-08-24 11:32:31 +02:00
( defmethod remove-update-hook ( ( bot tg-bot ) key )
2019-08-20 23:57:44 +02:00
( with-slots ( update-hooks ) bot
( let ( ( pos ( position key update-hooks :key #' car ) ) )
( when pos
( setf update-hooks ( nconc ( subseq update-hooks 0 pos ) ( nthcdr ( 1+ pos ) update-hooks ) ) ) )
pos ) ) )
2019-09-20 15:51:55 +02:00
;;; Process Updates
2019-08-18 22:29:48 +02:00
( defgeneric process-updates ( bot updates )
( :documentation "Processes the updates fetched by FETCH-UPDATES to detect commands and replies." ) )
;; check types before
2019-09-20 15:51:55 +02:00
2019-08-18 22:29:48 +02:00
( defmethod process-updates :before ( bot updates )
2019-09-20 15:51:55 +02:00
( declare ( type ( vector *update ) updates ) )
( log:debug "Processing ~a update(s)." ( length updates ) ) )
2019-08-18 22:29:48 +02:00
2019-09-02 21:06:06 +02:00
( defun read-new-timeout ( )
( format t "Enter a new timeout: " )
( multiple-value-list ( eval ( read ) ) ) )
2019-09-20 15:51:55 +02:00
;; TODO: make slimer with functions
2019-08-24 11:32:31 +02:00
( defmethod process-updates ( ( bot tg-bot ) updates )
2019-09-20 15:51:55 +02:00
( break )
( with-slots ( reply-queue update-hooks commands ) bot
2019-08-20 23:57:44 +02:00
( let ( ( unresolved nil ) ) ;; Process reply-matchers
2019-08-18 22:29:48 +02:00
( loop for update across updates do
2019-08-20 23:57:44 +02:00
( dolist ( matcher-list reply-queue )
2019-09-17 14:54:49 +02:00
( destructuring-bind ( promise matcher result timeout ) matcher-list ; TODO: use struct
2019-09-02 21:06:06 +02:00
( let ( ( reply ( apply matcher ( list update result ) ) ) )
( if ( or ( not timeout ) ( > timeout ( get-universal-time ) ) )
( if reply
( lparallel:fulfill promise reply )
( push matcher-list unresolved ) )
2019-09-17 14:54:49 +02:00
( restart-case ( error 'reply-matcher-timeout-error :timeout timeout )
2019-09-02 21:06:06 +02:00
( remove-handler ( ) nil )
( reset-timeout ( new-timeout )
:interactive read-new-timeout
( progn
( setf ( fourth matcher-list ) ( + ( get-universal-time ) new-timeout ) )
( push matcher-list unresolved ) ) ) ) ) ) ) )
2019-09-20 15:51:55 +02:00
( dolist ( command commands )
( when ( slot-boundp update 'message )
( destructuring-bind ( regex callback sep ) command
( let ( ( message ( tg-message update ) ) )
( when ( eq ( elt message 0 ) #\\ )
( let* ( ( space ( position #\Space message ) )
( command ( subseq message 0 space ) ) ) )
( when ( ppcre:scan regex command )
( let ( ( arg-string ( subseq message ( 1+ space ) ) )
( args ( if sep
( ppcre:split sep arg-string )
arg-string ) ) )
( funcall callback args message ) ) ) ) ) ) ) )
2019-08-24 11:32:31 +02:00
( dolist ( hook update-hooks ) ; process hooks
( funcall ( cdr hook ) updates ) ) )
2019-08-18 22:29:48 +02:00
( setf reply-queue unresolved ) ) ) )
2016-08-14 04:35:03 +02:00
2019-08-17 22:38:28 +02:00
( defun recursive-change-class ( object class )
2019-08-16 17:35:34 +02:00
"Casts and object and its members into the telegram specific classes."
2019-08-17 22:43:08 +02:00
( when ( and ( listp class ) ( > ( length class ) 1 ) ( eq ( car class ) 'array ) )
2019-09-02 22:06:16 +02:00
( setf class ( second class ) ) ) ; handle arrays
2019-08-17 22:43:08 +02:00
( unless ( find class *api-types* )
( return-from recursive-change-class object ) )
2019-08-16 17:35:34 +02:00
( when ( arrayp object )
( return-from recursive-change-class
( map 'vector #' ( lambda ( value )
2019-08-17 22:38:28 +02:00
( recursive-change-class value class ) )
2019-08-16 17:35:34 +02:00
object ) ) )
2019-08-17 22:43:08 +02:00
2019-08-17 22:38:28 +02:00
( change-class object class )
( dolist ( slot ( c2mop:class-slots ( find-class class ) ) )
2019-09-02 22:06:16 +02:00
( let ( ( name ( slot-definition-name slot ) )
( type ( slot-definition-type slot ) ) )
2019-08-16 17:35:34 +02:00
( when ( slot-boundp object name )
( let ( ( value ( slot-value object name ) ) )
2019-08-17 22:43:08 +02:00
( when value
2019-08-16 17:35:34 +02:00
( recursive-change-class value type ) ) ) ) ) )
object )
2019-08-18 22:29:48 +02:00
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
2019-09-20 15:51:55 +02:00
; CONVENIENCE INTERFACE ;
2019-08-18 22:29:48 +02:00
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
2019-08-17 22:38:28 +02:00
( defun get-latest-update-id ( updates )
"Finds the latest update id from a sequence of updates."
( reduce #' max updates :key #' tg-update--id :from-end t ) )
2019-08-18 22:29:48 +02:00
( defgeneric fetch-updates ( bot &key limit timeout )
( :documentation "Fetches updates from the API. See https://core.telegram.org/bots/api#getupdates." ) )
2019-08-24 11:32:31 +02:00
( defmethod fetch-updates ( ( b tg-bot ) &key limit ( timeout 1 ) )
2016-08-14 13:18:43 +03:00
( let* ( ( current-id ( id b ) )
2019-08-18 22:29:48 +02:00
( results ( $ ( get-updates
:limit limit
:timeout timeout
:offset current-id )
2019-09-02 21:06:06 +02:00
( :bot b :timeout ( 1+ timeout ) ) ) ) )
2016-08-14 04:35:03 +02:00
( when ( > ( length results ) 0 )
2019-08-17 22:38:28 +02:00
( let ( ( id ( get-latest-update-id results ) ) )
2019-08-16 17:52:07 +02:00
( setf ( id b ) id )
( incf ( id b ) 1 ) ) )
2019-08-18 23:48:37 +02:00
( when ( > ( length results ) 0 )
( process-updates b results ) )
2016-08-14 04:35:03 +02:00
results ) )
2019-08-18 22:29:48 +02:00
2019-08-20 23:57:44 +02:00
( defmacro wrap-$ ( &rest body )
"Wraps all forms following (:INLINE [$|$*] ...) into ([$|$*] ...)$."
( let* ( ( index
( position nil body
:test #' ( lambda ( _ el )
( declare ( ignore _ ) )
( and ( listp el ) ( eq ( car el ) :inline )
( or ( eq ( second el ) '$* )
2019-08-24 11:32:31 +02:00
( eq ( second el ) '$ ) ) ) ) ) ) )
2019-08-20 23:57:44 +02:00
( if ( and index ( < index ( 1- ( length body ) ) ) )
2019-08-24 11:32:31 +02:00
` ( progn ,@ ( subseq body 0 index ) , ( concatenate 'list ( cdr ( nth index body ) ) ( subseq body ( 1+ index ) ) ) )
` ( progn ,@ body ) ) ) )
2019-08-20 23:57:44 +02:00
2019-08-18 22:29:48 +02:00
( defun make-optional-body ( body return-var return-val-sym )
"Make the body part of the $ (api call macro)."
( if body
` ( let ( ( , return-var , return-val-sym ) )
2019-08-24 11:32:31 +02:00
( wrap-$ ,@ body ) )
2019-08-18 22:29:48 +02:00
return-val-sym ) )
2019-09-02 21:06:06 +02:00
( defun make-$-method-call ( method bot args timeout )
2019-08-18 22:29:48 +02:00
"Generate a call to MAKE-REQUEST."
2019-09-02 21:06:06 +02:00
` ( apply #' make-request ( nconc ( list , bot ) ( , method ,@ args ) ( list :timeout , timeout ) ) ) )
2019-08-18 22:29:48 +02:00
( defmacro $* ( ( method &rest args ) &body body )
"Call api method with standard BOT and RESULT-VAR. See $."
` ( $ ( , method ,@ args ) ( ) ,@ body ) )
( defmacro $ ( ( method &rest args )
2019-09-02 21:06:06 +02:00
( &key ( bot '*bot* ) ( return-var +return-var+ ) ( parallel nil ) ( with-reply nil ) ( timeout 10 ) )
2019-08-18 22:29:48 +02:00
&body body )
"Call an API method. If a body is given the result of the call will be bound to RETURN-VAR and the body will be included. Subsequent calls to $ can be inlined like ($ ... FORMS (:INLINE $ ...) FORMS*) => ($ ... FORMS ($ ... FORMS*))."
( when ( not ( find method *api-methods* ) ) ( error "No such API method." ) )
( let ( ( return-val-sym ( gensym ) ) )
2019-08-18 23:48:37 +02:00
` ( let* ( ( , return-val-sym , ( if parallel
2019-09-02 21:06:06 +02:00
` ( lparallel:future , ( make-$-method-call method bot args timeout ) )
( make-$-method-call method bot args timeout ) ) )
,@ ( when with-reply
( destructuring-bind ( reply-sym matcher &key timeout ) with-reply
` ( ( , reply-sym
, ( if parallel
` ( lparallel:future
( lparallel:force
( add-reply-matcher , bot , matcher ( lparallel:force , return-val-sym ) , timeout ) ) )
` ( add-reply-matcher , bot , matcher , return-val-sym , timeout ) ) ) ) ) ) )
2019-08-18 22:29:48 +02:00
, ( make-optional-body body return-var return-val-sym ) ) ) )
2019-09-20 15:51:55 +02:00
( defgeneric add-command ( bot name-regex callback &optional sep )
( :documentation " Adds a chat command and calls CALLBACK with a list
of arguments and the corresponding *MESSAGE object if NAME-REGEX ( a
string ) matches the command text ( without / ) . The Arguments to the
command are split along SEP ( per default no splitting ) . " ) )
( defmethod add-command :before ( bot name-regex callback &optional sep )
( log:debug "Adding command: " ( list bot name-regex callback sep ) ) )
( defmethod add-command ( ( bot tg-bot ) name-regex callback &optional sep )
( let ( ( name-regex ( ppcre:create-scanner name-regex ) ) )
( push ( list name-regex callback sep ) ( slot-value bot 'commands ) ) ) )
( defmacro definecommand ( ( bot name-regex &key error-message ( sep nil ) )
argument-lambda-list &body body )
( let ( ( args ( gensym ) )
( message ( gensym ) ) )
` ( add-command , bot , name-regex
#' ( lambda ( , args , message )
( declare ( ignore , message ) )
( handler-bind ( ( error #' ( lambda ( e ) ( log:error "Invalid args ~a" args ) ) ) )
( destructuring-bind , argument-lambda-list args ,@ body ) ) )
sep ) ) )
2019-08-18 22:29:48 +02:00
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Convenience Wrappers ;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
( defun make-inline-keyboard ( keys )
"Make an inline keyboard markup from an array of arrays of initializer lists of *INLINE-KEYBOARD-BUTTON."
( declare ( type list keys ) )
( make-instance
'*inline-keyboard-markup
:inline--keyboard ( mapcar
#' ( lambda ( keys )
( mapcar #' ( lambda ( key )
( apply #' make-instance ` ( *inline-keyboard-button ,@ key ) ) )
keys ) )
keys ) ) )
2019-08-20 23:57:44 +02:00
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Reply Matchers ;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Reply matchers all take an *UPDATE and an API request answer
;; object. They return nil if the *UPDATE is not the desired answer
;; or otherwise an arbitrary value that will passed on as reply.
( defun inline-keyboard-answer ( update result )
"A reply matcher to use for inline keyboard messages. Yields a *CALLBACK-QUERY object."
( when ( slot-boundp update 'callback--query )
( let ( ( cb ( tg-callback--query update ) ) )
( when ( and cb ( = ( tg-message--id result )
( tg-message--id ( tg-message cb ) ) ) )
cb ) ) ) )