Spec For Smarter SMS Bot

By: dreev
Last updated: 2020-04-23
Gissue: #908

I sure hate how you have to prefix every datapoint to the SMS bot with the goalname. In fact, I hate that so much that I’ve worked out a fully functional spec for how to avoid it!

Let’s dive in. We’ll have two new database fields in the user table to remember the last goal that the SMS bot asked about, and when. (The when part is for a technical reason discussed below.) Think of interacting with the bot as a dialog — the bot has to always know the goal being talked about. Also you can send just a goalname to the bot and it will send back your status on that goal. Bonus feature!

Already this mostly Pareto dominates the status quo. With this smarter bot you’ll normally be able to reply to the bot with just datapoint SEND but at worst you’ll have to do goalname SEND datapoint SEND instead of the old status quo of goalname SPACE datapoint SEND.

But in fact we can have actual Pareto dominance by just having the old version still work too. The regexes make that easy-peasy. If you don’t want to worry what the bot last reminded you of and don’t want to send two messages — maybe you’re setting up something automatic or just have a constant deluge of zeno alerts — then you can still prefix the goalname. We’ll call that advanced usage that casual users needn’t know about.

That’s the biggest advantage of this spec: for casual users they can just reply with data, same as the email bot. Having to understand about prefixing the goal name is Serious Friction.

This spec also fixes an existing bug where if you send STOP it (presumably) does stop but it responds to everything you send it as if nothing’s wrong. It should make you opt back in before you can interact with it! Otherwise you can forget that it’s not actually activated and be surprised that it’s failing to alert you. It’s a pretty big silent failure!

New Database Fields

We’re adding these to the user table:

Blurbs, AKA The Botcopy

We define these as constants/functions like so:

blurb_error_inactive = 
Error! Before you can talk to the Beeminder Text Bot you have to send START or A
CTIVATE to opt in!

blurb_error_already_active = 
You've already activated the Beeminder Text Bot! You'll receive alerts for graph
s you've marked for text updating. Send HELP for help.

blurb_ack_activate = 
Thanks for activating the Beeminder Text Bot! You will now receive alerts for gr
aphs you've marked for text updating. Send HELP for help.

blurb_ack_deactivate = 
Buzzing off! To restart notifications any time, send START or ACTIVATE. Until th
en, beemind yourself!

blurb_error_already_inactive = 
Already buzzed off! To restart notifications any time, send START or ACTIVATE. U
ntil then, beemind yourself!

blurb_help = 
Send STOP/START to stop/start Beeminder texting you (falls back to email). Send 
just goalname (or as many initial letters as it takes to disambiguate) to change
 what goal you're adding data to. Otherwise it's whatever goal you were last ale
rted about.
---------------- old status quo help text below for comparison -----------------
Send STOP to stop Beeminder texting you (falls back to email). START to reactiva
te. Tip: goalname only needs as many initial letters as it takes to disambiguate

send blurb_error_not_found(s) =
Error: No command or goal "{s}" (nor any goal uniquely identified by the prefix 
"{s}"). Send HELP for help.

blurb_error_no_which = 
Error! We don't know what goal to add that datapoint to! Wait for a reminder or 
generate one by texting a goalname. Send HELP for help.

blurb_error_unparsable = 
Error! We can't parse that as a command or goalname or datapoint! Send HELP for 
help.

Core Algorithm / Bizness Logic

And now the pseudocode. This is how we process an incoming SMS message, s, from a user.

The DATA_REGEX constant is the monster regex for datapoints from d.glitch.me that matches a datapoint like “^ 1” or whatever.

if s is "START" or "ACTIVATE"
  if sms inactive: 
    activate sms
    send blurb_ack_activate
  else
    send blurb_error_already_active
else if s is "STOP"
  if sms active
    deactivate sms
    send blurb_ack_deactivate
  else
    send blurb_error_already_inactive
else if s is "HELP": send blurb_help
else if s matches /^\w+$/ (ie, just a goalname)
  if s uniquely identifies a goal g
    if smswhich != g { smswhich = g; smswhen = now }
    send standard reminder/zeno for goal g
  else
    send blurb_error_not_found(s)
else if s matches DATA_REGEX
  if smswhich == null
    send blurb_error_no_which
  else
    add datapoint s to goal smswhich
    reply as usual
else if s matches /^(\w+)\s+({DATA_REGEX})$/
  let p (for prefix) be the goalname or partial goalname (1st capture group)
  let s be the datapoint (2nd capture group in the regex)
  if p uniquely identifies a goal g
    add datapoint s to goal g
    if smswhich != g { smswhich = g; smswhen = now }
    reply as usual
  else
    send blurb_error_not_found(p)
else
  send blurb_error_unparsable

Out of Order Messages

This is the part where we explain why we’re keeping track of when smswhich changes, via the smswhen field.

If we send multiple zenos at once, or even in close succession, then we have no guarantee that they stay in order. A user may reply to the last message they see but that’s not necessarily the last message we sent.

Twilio tells us that messages typically reach their destination in 5-10 seconds depending on country and carrier and that taking 30 seconds is unusual enough for it to be marked as “delayed”.

So our answer to making (reasonably) sure that our messages are in order is to insert a delay of 30 seconds. It’s not a guarantee but if it does happen that a user replies to a different reminder than they thought they were replying to, it’s at least a fairly loud failure since the bot’s reply points to the graph it updated.

(A delay of 30 seconds also gives the user time for a quick +1 reply. And note that the delay only applies when the reminder is about a different goal, so zenos for a single goal hitting in quick succession as the deadline approaches will have no delays. [UPDATE: No, this parenthetical has a problem; see open questions and prefix above. HT Christopher Moravec.]

To effect the delay we just insert the following before sending a zeno:

sleep(max(0, smswhen+30-now))

That pauses for 0-30 seconds to ensure there’s at least a 30 second gap between reminders about different goals. If it’s bad for a worker thread to be tied up for so long while sleeping, it can also just reschedule itself for that many seconds in the future.

Open Questions

  1. Is it ok to exceed 160chars for the help text response? Default answer: It’s ok enough. Twilio says most modern phones and networks automatically split and recombine messages up to 1600 characters. If we see cases of that going wrong we can split the help response in two and send them separately ourself.
  2. What if someone has a goalname “start”, “activate”, “stop”, or “help” (or whatever other sms command)? Default answer: You’re out of luck. We can wait till the first time someone complains. And “out of luck” just means that you have to wait for an alert to add data for such goals.
  3. Should we let “stop” have an argument — “stop all” vs “stop goal” or something? If so, is it fine for just “stop” to be the same as “stop all”? By default we can say that’s not needed. You can go to beeminder.com/reminders for picking which goals should text you.
  4. Could someone like user-mary have so many goals zenoing that the delays to ensure ordering add up to too much? Default answer: It’ll be fine. SMSes can be delayed anyway.
  5. [Discussion about pathological cases where 20 goals get zenos scheduled at once, all but one of them gets sent to the back of the queue, etc. Arguably, the extent to which that’s pathological is the extent to which we should be doing some throttling anyway to prevent trying to send a single user 20 text messages at the same time. But more discussion is needed here!]
  6. We think we now see the right thing to do and this whole spec is on pause awaiting another prereq. The right thing will probably be a general throttling mechanism. When a bot (SMS or Slack) wants to zeno it sees how many other goals want to zeno and collapses them so we never send more than one reminder in any 5-minute window. Then the whole question of messages coming in out of order (or a new message appearing right as you’re about to hit send on a reply to a previous one! HT Christopher) becomes moot.

Acknowledgments

Thanks to Bee Soule and Mary Renaud for helping solve the problem of users who need the SMS bot to be stateless, and to Mary for pointing out the problem with out-of-order messages. Thanks to Christopher Moravec for helping identify a new prereq. Thanks to Faire Soule-Reeves for reading and discussing a draft of this spec.