Fun with Twilio

Table of Contents

Introduction

Twilio is a service for programmatically sending and recieving sms (short message service) text messages. Reasons that SMS is interesting interface for software:

  • Interacting with a program via sms doesn't require a smart phone or even a data plan, and almost all cell phone services come with unlimited sms messaging. This makes SMS-based applications more accessible.
  • Interacting via text is very simple and distraction free.
  • Simple to program, no need for fancy GUI elements.

I made a simple expense tracker app to explore this concept.

How to use Twilio

Here is a rough description of the architecture - refer to the Twilio documentation and the example program below for more details. First, you buy a number on twilio. This is a couple bucks a month at the time of this writing (<2024-01-15 Mon>). Then, within the Twilio Console, you specify a URL that handles incoming messages. Each message triggers an HTTP POST request to the url, with the source number and message encoded in the request body. Sending messages is also done via an HTTP request to the Twilio API endpoint, with the destination and message contents again encoded in the request body.

Spending tracker

This is a little app for tracking your spending. It's operation is very simple. The first time you message it, a new database entry is initialized for your number and a welcome message is sent. To record some spending, simply message it a number and a note .e.g "45.32 gas" to record that you spend $45.32 on gas. (push-dispatcher mysrv (my-prefix-disp "/sms" (quote sms-dispatch)))

It has three other commands too:

  • quotas: report how much has been spent (yes, balance would be a better choice here.).
  • reset: reset the spending amounts back to zero.
  • set: set the spending amount to a specific level.

Records of all the transactions are saved to a CSV file that you can browse later if you're interested in your spending habits.

twilio.jpg

The code is given below. Note that before it is used, you will need to define the variables sid, token, and tracknumber, filling in the values below based on your twilio number and credentials:

(defvar sid "your_sid_here")
(defvar token "your_token_here")
(defvar tracknumber "your_number_here") 

Here is sms.lisp. The this is meant to be used in conjunction with the Lisp web app frame work state.lisp.

(require "str")
(require "drakma")
(require "cl-store")
(require "local-time")

(defvar all_dbs (make-hash-table :test (quote equal)))

(defun reset-costs (costs)
  (setf (gethash "misc" costs) 0)
  "Values reset")

(if (probe-file "costs.obj")
    (progn
      (print "restoring costs...")
      (setf all_dbs (cl-store:restore "costs.obj"))))

(defun add-number (num)
      (if (not (gethash num all_dbs))
          (progn
            (print (format nil "Initializing DB for ~a" num))
            (setf (gethash num all_dbs)
                  (make-hash-table :test (quote equal)))
            (reset-costs (gethash num all_dbs)))
        (print (format nil "Entry already found for ~a" num)))
      "Number added. Welcome.")

(defun allitems (costs)
  (with-output-to-string (s)
    (maphash
     (function (lambda (key val)
                 (format s "~a: $~a~%" key val)))
     costs)))

(defun sendsms (number msg)
  (drakma:http-request
   (format nil "https://api.twilio.com/2010-04-01/Accounts/~a/Messages.json" sid)
   :method :post
   :parameters (list (cons "To"  number)
                     (cons "From"  tracknumber)
                     (cons "Body"  msg))
   :basic-authorization (list sid token)))

(defun str-to-float (data)
  (with-input-from-string (in data) (read in)))

(defun report-balance (cmd costs)
  (let ((bal (gethash cmd costs)))
    (if bal
        (format nil "~a balance is $~a." cmd bal)
      "Invalid command")))

(defun update-balance (cmd arg note costs from)
  (let ((amt (str-to-float arg))
        (ctime (local-time:format-timestring
                nil
                (local-time:now)
                :format local-time:+rfc-1123-format+)))
    (if (gethash cmd costs)
        (prog1
            (format nil "~a balance is now $~a" cmd
                    (incf (gethash cmd costs) amt))
          (cl-store:store all_dbs "costs.obj")
          (with-open-file (s (format nil "log-~a.txt" from)
                             :direction :output
                             :if-exists :append
                             :if-does-not-exist :create)
                          (format s "~a~C~a~C~a~C~a~%"
                                  ctime #\tab cmd #\tab arg #\tab note)))
      "Invalid command")))

(defun setamt (cmd arg costs)
  (let ((amt (str-to-float arg)))
    (if (gethash cmd costs)
        (prog1
            (format nil "~a balance is now $~a" cmd
                    (setf (gethash cmd costs) amt)))
      "Invalid command")))

(defun process (from body costs)
  (destructuring-bind
   (arg &rest note)
   (str:split " " body)
   (cond
    ((equal arg "quotas") ;; they want to see all quotas
     (sendsms from (allitems costs)))
    ((equal arg "reset")
     (sendsms from (reset-costs costs)))
    ((equal arg "set")
     (sendsms from (setamt "misc" (first note) costs)))
    (t ;; else - we have both a command and an arg so assume they're updating a balance
     (sendsms from (update-balance "misc" arg note costs from))))))

(defun sms-dispatch (req)   ;; This function is called by the server when a new HTTP request comes in.
  (let ((from (post-parameter "From" req))
        (body (post-parameter "Body" req)))
    (if (gethash from all_dbs)
        (map nil (lambda (onebody)
                   (process from onebody (gethash from all_dbs)))
             (str:split #\Newline body))
      (sendsms from (add-number from))))
  "OK")

To use this with state.lisp, we just need to add a dispatcher:

(load "state.lisp")
(load "sms.lisp")
(push-dispatcher *mysrv* (my-prefix-disp "/sms" (quote sms-dispatch)))

Date: 2022-11-29 Tue 00:00

Email: [email protected]

Validate