osa1 github gitlab twitter cv rss

Common Lisp ile IRC botu ve web arayüzü

January 6, 2012 - Tagged as: lisp, tr.

Tamamen Common Lisp ile basit bir IRC botu ve web arayüzünün nasıl yazılabileceğinden bahsedeceğim biraz. Web kısmında web server dahil herşey yine Common Lisp ile yazılmış olacak.

Gistler: arayüz bot

Öncelikle botun ne yapacağına karar verelim, benim amacım çalışan en minimal botu yazmak. Daha sonra üzerine istediğimiz özelliği ekleyebiliriz. Bu yüzden şimdilik sadece bağlandığı kanalları ve kendisine atılan özel mesajları kaydedip, bir web sayfasında yayınlayacak.

/images/bot-web-ui.png

Öncelikle IRC sunucusuna bağlanabilmemiz için bir socket kütüphanesine ihtiyacımız var(evet Common Lisp standardı socket içermiyor). Bu iş için usocket’i seçtim(uğraşacak olan varsa bir diğer alternatif de iolib, fakat ikisi için de işe yarar dökümantasyon yok dolayısıyla her türlü yolumuzu kendimiz bulmamız gerek).

Programımızın ana döngüsü gayet basit:

(defun run ()
  (let* ((socket (socket-connect "irc.freenode.org" 8001))
         (socket-stream (socket-stream socket))
         (start-time (get-universal-time)))
    (loop
      (let ((msg (read-line socket-stream)))
        (format t "~A~%" msg) ;; debug ve gozetleme amacli
        (multiple-value-bind (prefix command params) (parse-msg msg)
          (handle-command prefix (intern (string-upcase command)) params socket-stream)))
      (let ((time-passed (- (get-universal-time) start-time)))
        (when (> time-passed (* 1 30))
          (update-html)
          (setf start-time (get-universal-time)))))))

Bir socket oluşturup Freenode sunucularına bağlanıyoruz. Socket’e yazma ve socket’den okuma işlemlerini socket’in stream’ine yapacağız1 read-line ile sunucudan her seferinde bir tam satır okuyoruz ve parse-msga gönderiyoruz. parse-msg gelen mesajı IRC RFC’de belirtilen mesaj formatında göre prefix, command ve params olarak 3 parçaya bölüyor ve Common Lisp’in values özel formu ile bunları dönüyor2 Daha sonra bu parçaları handle-command generic fonksiyonuna gönderiyoruz. handle-command command parametresine göre gerekli dispatch fonksiyonunu çağırıyor3 Daha basit olamazdı. Son olarak yeterli vakit geçtiyse(ben 30 saniyede bir güncelliyordum sık sık debug ile uğraştığımdan), static html sayfalarını güncelleyecek olan update-htmli çağırıyoruz. Burda zamanı çok da düzgün tutmadığımıza dikkat. Eğer socket’den 10 dakika yanıt gelmezse 30 saniyede bir de güncelliyor olsak 10 dakika beklemek zorundayız4

Mesajları parçalara ayıran fonksiyonumuz şöyle:

(defun parse-msg (msg)
  "Parse irc message to prefix, command and params.
http://www.irchelp.org/irchelp/rfc/chapter2.html#c2_3_1
<message> ::=
    [':' <prefix> <SPACE> ] <command> <params> <crlf>
<prefix> ::=
    <servername> | <nick> [ '!' <user> ] [ '@' <host> ]
<command> ::=
    <letter> { <letter> } | <number> <number> <number>
<params> ::=
    <SPACE> [ ':' <trailing> | <middle> <params> ]
"
  (let* ((first-space (position #\space msg))
         (first (subseq msg 0 first-space))
         (rest (subseq msg (1+ first-space)))
         (prefix (if (eq (elt first 0) #\:)
                     (subseq first 1)
                     nil))
         (second-space (position #\space rest)))
    (if prefix
        (let ((command (subseq rest 0 second-space))
              (params (subseq rest (1+ second-space))))
          (values prefix command params))
        (let ((command (subseq first 0 first-space))
              (params rest))
          (values nil command params)))))

Ben burda ciddi bir şekilde parse etmektense, önek, komut ve parametreleri birbirlerinden ayıran boşluklar olduğunu farkettim ve basitçe bu boşluklara göre ayırdım. Saatlerdir log tutuyor henüz bir problem yaşamadım.

Mesajları parçaladıktan sonra şu generic fonksiyona gönderiyoruz:

(defgeneric handle-command (prefix command params socket-stream))

Bu fonksiyonu çağırırken command parametresinin her zaman bir sembol olması lazım. Başka türlü command parametresine göre dispatch fonksiyonuna karar veremiyoruz5 Ana döngüde string’i sembole çevirdiğim hacky kısım bu yüzden.

Şu aşamada ilgimizi çeken 3 komut var. PRIVMSG, NOTICE, ve PING. PING komutunu sunucu bize, uzun süre yanıt vermediğimiz için gönderecek(sürekli dinlemede olacağımızdan) ve hemen PONG cevabını vermemiz lazım. PRIVMSG herhangi bir kanala veya bize bir mesaj gönderildiğinde gelecek. NOTICEde ne zaman login olmak için komut göndermemiz gerektiğine karar vermemiz için. Burda en kritik olanı PRIVMSG, diğerlerine gist’den bakabilirsiniz:

(defmethod handle-command (prefix (command (eql 'privmsg)) params socket-stream)
  (let ((channel-or-nick (subseq params 0 (position #\space params)))
        (sender (subseq prefix 0 (position #\! prefix)))
        (msg (subseq params (+ 2 (position #\space params)))))
    (multiple-value-bind (channel-message-queue channel-exists)
        (gethash channel-or-nick *channels*)
      (unless channel-exists
        (setf (gethash channel-or-nick *channels*) '()))
      (setf (gethash channel-or-nick *channels*)
            (cons (make-message :msg msg
                                :sender sender) channel-message-queue)))))

*channels*, tüm kanallar için bir liste tuttuğumuz hash tablomuz. Burda static web sayfalarını güncelleme vaktimiz gelene kadar gelen mesajları tutuyoruz(gelen mesajı yine IRC RFC’nin şu bölümüne göre parçalıyorum). Mesajları tuttuğumuz yapımız basitçe sadece mesajın içeriğini ve göndereni tutuyor:

(defstruct message msg sender)

Son olarak static sayfaları güncellemek için çağırdığımız update-html:

(defun update-html ()
  (loop for channel-or-nick being the hash-keys of *channels* do
    (let ((msgs (reverse (gethash channel-or-nick *channels*))))
      (with-open-file (file-stream (concat "/home/sinan/Desktop/cl/logs/"
                                           (if (equal #\# (elt channel-or-nick 0))
                                               (subseq channel-or-nick 1)
                                               "direct-messages")
                                           ".html") ;; remove # from channel name
                                   :direction :output
                                   :if-exists :append
                                   :if-does-not-exist :create)
        (with-html-output (file-stream)
          (dolist (msg msgs)
            (let ((message-text (concat (message-sender msg)
                                        "> "
                                        (message-msg msg))))
              (htm (:p :class "msg" (str message-text)))))))
      (setf (gethash channel-or-nick *channels*) '()))))

Burda html çıktısını üretmek için cl-who kütüphanesini kullanıyoruz6 with-open-file ile kanal adına ait dosyayı açıp(yoksa oluşturup, varsa sonuna ekleyerek) with-html-output ile html elementlerini Lisp formları ve keywordler ile yazarak html kodunu dosyaya yazıyoruz ve hash tablomuzdaki mesaj listesini boşaltıyoruz(henüz tüm sayfayı oluşturmuyoruz, sadece mesajları html formatında kaydettik).

Şu anda sunucuda istediğiniz kanalları dinleyip kaydeden bir botumuz var(kod hakkında eksik olan birkaç tanımlama için en başta verdiğim gistlere bakabilirsiniz).

İkinci adım olarak web arayüzü. Static sayfaları sunmak için Hunchentoot kullanacağız. Bu gibi basit işler için inanılmaz rahat bir kütüphane.

(defvar server (make-instance 'easy-acceptor :port 4242
                                             :document-root "/home/sinan/Desktop/cl/static"))
(start server)

Hunchentoot ile 4242. portu dinleyen bir sunucu oluşturduk ve başlattık. document-root, static dosyaların(css dosyaları, resimler vs.) tutulduğu klasör. Hunchentoot sayfa yönlendirmelerini *dispatch-table* listesinden yapıyor. Yönlendirme işlemi birkaç farklı dispatcher ile yapılabiliyor ama biz şu anki basit sayfamız için sadece kanal adlarını yönlendirmekle ilgileneceğiz. Bu yüzden kullanacağımız dispatcher prefix-dispatcher olacak.

(defmacro define-url-fn ((name) &body body)
  `(progn
     (defun ,name ()
       ,@body)
     (push (create-prefix-dispatcher ,(format nil "/~(~a~).html" name) ',name) *dispatch-table*)))

Genel olarak sayfa oluşturma yapımız bu. (define-url-fn (sayfa-adi) icerik) şeklinde çağırdığımızda, localhost:4242/sayfa-adi adresinde iceriki gösterek şekilde ayarlıyor. Bu kadar basit. Şimdi sayfa içeriğimizi oluşturmadan önce her sayfada olacak kısımları ayıralım:

(defmacro standard-page ((&key title) &body body)
  `(with-html-output-to-string (*standard-output* nil :prologue t :indent t)
     (:html
      (:meta :charset "utf-8")
      (:head
       (:title ,title)
       (:link :type "text/css"
              :rel "stylesheet"
              :href "/static/reset.css")
       (:link :type "text/css"
              :rel "stylesheet"
              :href "/static/main.css"))
      (:body
       (:div :class "main"
             ,@body)))))

Bu şekilde sadece bir sayfayı diğer sayfadan ayıracak kısımlarla ilgileneceğiz ve standard-page macrosuna göndereceğiz. Web arayüzümüz tamamen ayrı bir program olduğundan, önce logların tutulduğu klasöre bakıp kanal listesini çıkaralım:

(defun list-file-names (&optional (folder *log-folder*))
  (mapcar (lambda (pathname)
            (let ((filename (file-namestring pathname)))
              (pathname-name filename)))
          (directory (make-pathname :directory folder :name :wild :type "html"))))

Burda yapılan bariz gibi. Bir klasördeki html dosyalarının adlarını listeliyoruz. Bu kadar. Bu aşamadan sonra ana menü(kanal listesinin bulunduğu) ve kanal loglarının görüntüleneceği sayfaları oluşturmak kalıyor.

;; ana menu
(define-url-fn (log-list)
  (standard-page (:title "log list")
    (:div :class "header" "Channel List:")
    (loop for log in (list-file-names)
          collect (htm (:div :class "menulink"
                             (:a :href (concat log ".html") (str log)))))))

Kanal log sayfalarını oluşturmak biraz zor oldu ve aslında yukarıda hazırladığım hiçbir macroyu kullanmadım. Benim gibi herhangi bir Lisp diline yeni başlayanlara bir not(gerçi Scheme macroları epey farklıymış, pattern matching yapabiliyorlarmış ve hijyeniklermiş): Bir macroya parametre olarak macro alan bir macro gönderiyorsanız ve macrolara tam olarak hakim değilseniz, debug etmek yerine elle yazmak daha pratik olabilir ehehe:

;; kanal loglari
(dolist (page-name (list-file-names))
  (let* ((in (open (merge-pathnames *log-folder*
                                    (make-pathname :name page-name
                                                   :type "html"))))
         (text (car (loop for line = (read-line in nil)
                          while line collect line))))
    (push
     (create-prefix-dispatcher
      (concat "/" page-name ".html")
      (lambda ()
        (standard-page (:title "Channel logs")
          (:div :class "header" (str (concat "Chat logs for #" page-name)))
          (str text))))
     *dispatch-table*)))

Ve bu kadar. Bot + web arayüzü toplamda 207 satır. Yorumlar dahil.

Eklenebilecekler:


  1. Common Lisp’in bir güzel yanı, lexical scope veya direkt olarak parametre olarak aktararak, kullanıcıya yazdırıp kullanıcıdan okuduğumuz fonksiyonların hepsini dosya, socket vs. için çok rahat kullanabiliyoruz↩︎

  2. Common Lisp’de bir fonksiyon birden fazla değer dönebiliyor, epey ilginç bir özellik, gerekliliği tartışılır tabii, sonuçta bir tuple/list/vs. dönmekten pek bir farkı yok, yanlızca eğer özel olarak belirtilmezse çağırana sadece otomatik olarak ilk değer dönüyor böylece her fonkisyon için “acaba kaç değer dönüyor?” diye düşünmüyoruz.↩︎

  3. Burada Clojure’dan bahsetmem lazım. Clojure multimethod’ları isteğe bağlı dispatch fonksiyonları ile çalışıyorken Common Lisp bu konuda daha kısıtlı. Clojure multimethodları hakkında şuraya bakabilirsiniz. Pascal usta buna benzer bir yapıyı Common Lisp için implement etmiş. Şurda biryerlerdeydi ama bulamadım şimdi.↩︎

  4. Socket’ler konusunda çok bilgili değilim. usocket ve iolib kütüphaneleri direkt olarak unix socketlerini(posix socketleri mi oluyor?) implement etmişler, api olarak çok benzerler. Yine de ben non-blocking io yapmayı bir türlü beceremedim. Thread’lerden de bir süredir nefret ediyorum. Common Lisp’de çok kullanılan Bordeaux Threads kütüphanesinde(Common Lisp standardının thread de içermediğini söylemiş miydim?) de timer yok.↩︎

  5. Common Lisp’in karşılaştırmayı eql fonksiyoinu ile yapmasıyla alakalı. Daha önce bahsettiğim kısıtlama.↩︎

  6. Lisp dillerinin bir başka güzel yanı: kendi syntaxları ile kolaylıkla herhangi bir markup dilini ifade edebilirsiniz(şansınızı zorlarsanız JavaScript’i bile ifade edebilirsiniz ama bana delilik gibi geliyor açıkçası, bkz. parenscript).↩︎