Telegram bot in NodeJS: pulsantiera personalizzata e menu a più livelli

Una delle cose più interessanti che ho incontrato durante i miei esperimenti scrivendo un Telegram bot in NodeJS è la possibilità di mostrare all'interlocutore una pulsantiera personalizzata, e guidarlo nella scelta di una opzione all'interno di un menu a più livelli. Questo approccio si sposa molto bene con il tipico utilizzo di Telegram, che avviene attraverso un dispositivo mobile con uno schermo touch e di dimensioni limitate.

Gli aspetti di questo paradigma di utilizzo sono sostanzialmente due:

  • offrire una pulsantiera, per evitare di dover scrivere la scelta con la tastiera integrata.
  • alterare la pulsantiera sulla base delle scelte precedenti, per guidare l'utente attraverso i diversi livelli di un menu.

Nell'esempio che mostrerò qui sotto, tratto da mio CovidBot, voglio offrire all'utente la possibilità di richiedere i dati di contagio in una regione, attraverso il comando /regione nome, oppure di usare il comando /regione senza parametri e scegliere la regione attraverso un menu di navigazione a due livelli: area (nordest, nordovest ecc..) e poi la regione vera e propria.

Tutto inizia con la predisposizione del callback onText con una opportuna regexp.

bot.onText(/\/region[ie]?([ ]+([a-zA-Z]+))?/, async (msg, match) => {....

Il comando accetta in input:

  • il testo /region seguito da una lettera facoltativa (?) nella lista [ie] (quindi risponde indifferentemente alla richiesta /regione, /regioni, /region.
  • Quanto segue è incluso in un gruppo facoltativo (......)?
  • Nel gruppo facoltativo c'è uno spazio facoltativo [ ]? seguito da un gruppo di uno o più caratteri alfabetici [a-zA-Z]+. Quest'ultimo è il gruppo destinato a catturare l'eventuale nome di regione passato direttamente nel comando.

Per spiegazioni più esaustive sul meraviglioso mondo delle regexp suggerisco questo link.

Il passo successivo è verificare se il secondo gruppo (quello interno, con il nome regione) è stato passato, e in tal caso gestirlo a dovere e concludere qui la procedura.

if (match[2]){
   ....
}

In caso negativo, l'utente non ha passato il nome della regione e vogliamo mostrare la pulsantiera con l'elenco delle aree.

// Lista aree (id univoco e testo descrittivo)
const AREAS = [
  {id: "nordovest", descr: "Nord-Ovest"},
  {id: "nordest", descr: "Nord-Est"},
  {id: "centro", descr: "Centro"},
  ...
]

const keyboard = AREAS.map(area => ({
  text: area.descr,
  callback_data: JSON.stringify({
    type: 'area',
    id_area: area.id
  })
}))

const opts = {
  reply_markup: {
    inline_keyboard: splitArray(keyboard, 3)
  }		
}

bot.sendMessage(msg.chat.id, 'Dati regionali. Seleziona area:', opts);

Nel codice qui sopra:

  • predisponiamo una lista di oggetti ciascuno dei quali descrive una area, con un id univoco e un testo descrittivo.
  • a partire dalla lista di cui sopra costruiamo un oggetto keyboard da passare nel nostro messaggio. L'oggetto contiene un testo da mostrare sul pulsante (la descrizione dell'area) e un callback_data. Il callback_data è un oggetto libero, che ci verrà restituito quando l'utente preme il relativo pulsante. Ci mettiamo dentro un tipo (area) e il codice dell'area, per poter risalire a quale tasto è stato premuto.
  • l'oggetto opts contiene i parametri che vogliamo mandare nel messaggio vero e proprio. In questo caso passeremo solo reply_markup.inline_keyboard, che conterrà la struttura della tastiera così come costruita prima. Nota: il metodo splitArray è un piccolo metodo di utilità che divide la lista in una lista di liste: ogni lista rappresenta una fila di pulsanti, e voglio metterne al massimo tre per ciascuna.
  • per concludere, inviamo all'utente il messaggio con le opzioni. Come destinazione usiamo msg.chat.id, che è il codice che Telegram ci ha passato nella chiamata al callback e che identifica l'utente o la stanza da cui il messaggio è stato inviato.

Adesso l'utente ha ricevuto il nostro messaggio con la tastiera custom.

Cosa succede quando viene premuto un pulsante? Viene chiamato nel bot un evento speciale, chiamato callback_query. Possiamo catturarlo con questa dicitura:

bot.on('callback_query', (callbackQuery) => {

  const msg = callbackQuery.message;
  const chat_id = msg.chat.id;
  const message_id = msg.message_id;

  const data = JSON.parse(callbackQuery.data);
  const {type} = data;

  switch(type){
    case 'area':
      manageAreaCallback(bot, chat_id, message_id, data);
      break;
      
......

  bot.answerCallbackQuery(callbackQuery.id);
}

Nella routine di gestione qui sopra, una volta catturato il callback andiamo a estrarne le informazioni principali:

  • l'id della chat (chat_id) che ci servirà per rispondere.
  • l'id del messaggio che ha generato questo callback (message_id), ovvero il nostro messaggio con la tastiera. Ci servirà per andare a rimpiazzare questo messaggio con uno nuovo contenente il menu successivo.
  • il pacchetto dati contenuto nel messaggio, quello che abbiamo predisposto in precedenza e, ricordiamo, contiene un tipo (area) e l'identificativo della regione.
  • Se il tipo è "area", chiamiamo il metodo di gestione corretto. Il tipo è utile poichè questo callback è chiamato da TUTTE le possibili tastiere custom, ed è quindi fondamentale distinguere quella che ha generato la richiesta.
  • La chiamata ad "answerCallbackQuery" è in ogni caso necessaria per indicare a Telegram che il callback è stato gestito correttamente.

Ora dobbiamo gestire il messaggio. Il meccanismo è analogo a prima: recuperiamo la lista delle regioni appartenenti a quell'area e inviamo un altro messaggio con una pulsantiera custom.

export function manageAreaCallback(bot, chat_id, message_id, data){

  const {id_area} = data;
  const area = REGIONS.find(area => area.id === id_area);
 
  const keyboard = area.regions.map(reg => ({
    text: reg.descr,
    callback_data: JSON.stringify({
      type: 'region',
      id_reg: reg.id,
      id_area: id_area
    })              
  }))
  
  keyboard.push({
    text: '<-',
    callback_data: JSON.stringify({
      type: 'areas_list'
    })
  })
  
  bot.editMessageText(
    `Hai chiesto regioni del ${area.descr}, seleziona regione:`,
    {
      chat_id: chat_id,
      message_id: message_id,
      reply_markup: {
        inline_keyboard: splitArray(keyboard, 3)
      }                   
    }
  )   

}

Le uniche particolarità di questo metodo sono:

  • alla lista delle regioni dell'area aggiungiamo un ulteriore pulsante "<-" con un type particolare, che nel nostro callback verrà interpretato come "torna indietro" e quindi mostrerà ancora la lista delle aree.
  • anzichè sendMessage usiamo il metodo editMessageText, che rimpiazza il messaggio precedente (identificato dal message_id).

Il risultato è come nell'immagine.

A questo punto il gioco è fatto. Andiamo ad arricchire il "callback_query" con le nuove casistiche e il gioco è fatto. Naturalmente in caso di callback con type "region" (pressione del tasto con il nome di una regione) dovremo inviare non più una tastiera ma un semplice messaggio con il contenuto informativo.

...
switch(type){
  case 'area':
    manageAreaCallback(bot, chat_id, message_id, data);
    break;
  case 'region':
    manageRegionCallback(bot, chat_id, message_id, data, regionalDataFull);
    break;
  case 'areas_list':
    manageAreasListCallback(bot, chat_id, message_id);
    break;      
}
...

Le modalità di invio del messaggio esulano dall'argomento di questo post. Per ulteriori dettagli tutto il codice del bot è pubblicato su github.

Spero che questa spiegazione sempliciotta sia servita al suo scopo, ovvero descrivere una possibilità e dare una indicazione di massima sul suo utilizzo in un caso concreto. Per ulteriori dettagli consiglio di rivolgersi alla documentazione ufficiale di node-telegram-bot-api, ricca di esempi e tutorial.

Alla prossima!