Let’s awk

Einen Großteil seiner Arbeit erledigt der Bot auf Systemebene

Die Scriptsprache der CCU ist begrenzt. Für die Verarbeitung von Strings gab es bis vor kurzem nicht viele Funktionen. Zudem reagiert die CCU erfahrungsgemäß manchmal etwas giftig auf exzessive String-Bearbeitung: Die Logikschicht wird instabil und bleibt irgendwann stehen.

Daher lasse ich die Auswertung der Telegram-Rückmeldung auf Systemebene von awk erledigen. awk  ist ein Standard-Tool unter unixoiden Betriebsystemen (wie dem der CCU), mit dem Texte zeilenweise ausgewertet und in einzelne Datenfelder aufgeteilt werden können. Praktischerweise kann man selbst festlegen, wie eine „Zeile“ und wie die Felder definiert sein sollen.

Zum Testen gehe ich per ssh auf die Kommandozeile der CCU. Erst, wenn mein awk-Programm das tut, was es soll, binde ich das Ganze in ein WebUI-Programm ein.

Benennung von Systemvariablen

Prinzipiell kann man Systemvariablen – so wie allen Objekten in der CCU – beliebige Namen geben, also z. B. auch Umlaute und Sonderzeichen verwenden. Ich empfehle jedoch, sich auf reguläre Buchstaben (a-z, A-Z) zu beschränken: Bei Umlauten und Sonderzeichen besteht die Gefahr, dass Systemvariablen in Scripten nicht überall gefunden werden.

Daten von Telegram abrufen

Als erstes rufe ich also die Daten mit den neuesten Nachrichten ab, die ich mit meinem CCU-Bot austausche. Ein einfaches Beispiel hatte ich bei der Einrichtung des Telegram-Frameworks beschrieben. Die Grundversion sieht so aus:

# wget --output-document=- --quiet --no-check-certificate "https://api.telegram.org/bot314321353:AAFKjr29dF940b-aTchoFJ_pb6oZKxzx8Zw/getUpdates"
{"ok":true,"result":[
	{"update_id":30412544,"message":{"message_id":2018,"from":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","language_code":"de-DE"},"chat":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","type":"private"},"date":1503256927,"text":"hallo"}},
	{"update_id":30412545,"message":{"message_id":2019,"from":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","language_code":"de-DE"},"chat":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","type":"private"},"date":1503256928,"text":"welt"}},
	{"update_id":30412546,"message":{"message_id":2020,"from":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","language_code":"de-DE"},"chat":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","type":"private"},"date":1503257422,"text":"wie geht's"}}
]}#

Ich habe die Rückmeldung mal etwas formatiert, damit man besser erkennt, dass sie aus drei Nachrichten besteht: Ich habe „hallo“, „welt“ und „wie geht’s“ an den Bot gesendet. Das Ergebnis kommt im JSON-Format daher. Was ich damit anstelle, kommt weiter unten.

Wichtig an dieser Stelle ist zunächst das Feld update_id, das für jede Nachricht hochgezählt wird. Die update_id kann man als offset an Telegram übergeben. Dadurch werden nur Nachrichten zurückgeliefert, die diese ID oder eine höhere haben. Vorherige Nachrichten werden bei Telegram gelöscht.

# wget --output-document=- --quiet --no-check-certificate "https://api.telegram.org/bot314321353:AAFKjr29dF940b-aTchoFJ_pb6oZKxzx8Zw/getUpdates?offset=30412545"
{"ok":true,"result":[
	{"update_id":30412545,"message":{"message_id":2019,"from":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","language_code":"de-DE"},"chat":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","type":"private"},"date":1503256928,"text":"welt"}},
	{"update_id":30412546,"message":{"message_id":2020,"from":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","language_code":"de-DE"},"chat":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","type":"private"},"date":1503257422,"text":"wie geht's"}}
]}#

Ich habe hier als offset die update_id der zweiten Nachricht übergeben und bekomme dementsprechend auch nur die zweite und dritte Nachricht zurück. Würde ich die vorherige Abfrage ohne offset jetzt noch einmal ausführen, würden ebenfalls nur die zweite und dritte Nachricht übermittelt werden – die erste Nachricht „hallo“ ist durch die Abfrage mit offset gelöscht worden.

Der zweite Parameter, mit dem ich hier was anfangen kann, lautet limit. Damit kann ich angeben, dass ich nur eine Nachricht bekommen möchte.

# wget --output-document=- --quiet --no-check-certificate "https://api.telegram.org/bot314321353:AAFKjr29dF940b-aTchoFJ_pb6oZKxzx8Zw/getUpdates?offset=30412545&limit=1"
{"ok":true,"result":[
	{"update_id":30412545,"message":{"message_id":2019,"from":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","language_code":"de-DE"},"chat":{"id":374629384,"first_name":"Christian","last_name":"L\u00fctgens","type":"private"},"date":1503256928,"text":"welt"}}
]}#

Ich kann also eine einzelne Nachricht abrufen, daraus die update_id auslesen und beim nächsten Abruf dann die nächste Nachricht mit update_id + 1 abrufen, so lange, bis keine neuen Nachrichten mehr vorliegen.

Damit ist meine wget-Abfrage im Prinzip fertig und ich kann mich awk zuwenden, um die ganze Sache mit Leben zu erfüllen.

JSON the hard way

Wie erwähnt, liefert Telgram die Daten als JSON-Objekt. Das ist schön, aber für meinen Anwendungsfall spare ich es mir, das Objekt tatsächlich komplett zu parsen. Ich breche die Antwort ganz brutal in die einzelnen Felder auf, die ich dann später auswerte.

Als erstes lege ich fest, dass jedes Feld eine neue Zeile ist. Das geschieht in awk mit der Defintion des Record Separators, kurz RS.

# wget --output-document=- --quiet --no-check-certificate "https://api.telegram.org/bot314321353:AAFKjr29dF940b-aTchoFJ_pb6oZKxzx8Zw/getUpdates?offset=30412545&limit=1" | 
awk 'BEGIN { RS="\"*[,\[\{\}\r\n]+\"*"; } // { print $0; }'

ok":true
result":
update_id":30412545
message":
message_id":2019
from":
id":374629384
first_name":"Christian
last_name":"L\u00fctgens
language_code":"de-DE
chat":
id":374629384
first_name":"Christian
last_name":"L\u00fctgens
type":"private
date":1503256928
text":"welt
]
#

Ich hole per wget dieselbe Nachricht ab, die ich im obigen Beispiel verwendet habe. Das awk-Programm sieht, etwas freundlicher formatiert, so aus:

BEGIN { 
  RS="\"*[,\[\{\}\r\n]+\"*"; 
} 

// { 
  print $0; 
}

Der Block hinter BEGIN wird ausgeführt, bevor awk beginnt, die wget-Ausgabe abzuarbeiten. Hier lege ich einen regulären Ausdruck fest, der alles als Trennzeichen umfasst, was im JSON-Ergebnis die Felder trennt: Es kommt manchmal ein Anführungszeichen, dann eine oder mehrere Klammern oder Zeilenumbrüche und dann noch ein Anführungszeichen oder auch nicht.

Die Anführungszeichen sind strenggenommen keine Datensatz-Trenner – ich setze sie hier nur ein, weil Feldnamen und Werte teilweise mit Anführungszeichen eingefasst sind, die ich hier mit einem Schritt eliminieren kann. Man sieht das beispielsweise in der Zeile first_name“:„Christian  – hier sind die Zeichen vor dem Feldnamen first_name und hinter dem Wert Christian bereits verschwunden.

Mit print $0 wird die gesamte Zeile ausgegeben. Details zu diesem Block kommen später – zumal dies kein awk-Kurs sein soll, sondern nur eine kurze Beschreibung, wie ich zu meinem kryptischen Script komme.

Der nächste Schritt ist also, die Felder zu trennen. Dafür definiere ich den Field Separator FS neu, der normalerweise auf Leerzeichen reagiert.

# wget --output-document=- --quiet --no-check-certificate "https://api.telegram.org/bot314321353:AAFKjr29dF940b-aTchoFJ_pb6oZKxzx8Zw/getUpdates?offset=30412545&limit=1" | 
awk 'BEGIN { RS="\"*[,\[\{\}\r\n]+\"*"; FS="\":\"*"; } // { print $1 " = " $2; }'
 =
ok = true
result =
update_id = 30412545
message =
message_id = 2019
from =
id = 374629384
first_name = Christian
last_name = L\u00fctgens
language_code = de-DE
chat =
id = 374629384
first_name = Christian
last_name = L\u00fctgens
type = private
date = 1503256928
text = welt
] =
#

Hier sieht man, wie die einzelnen Datensätze in Felder und Werte aufgeteilt sind, getrennt durch Gleichheitszeichen.

Auch hier noch einmal das Script in eleganter Formatierung:

BEGIN { 
  RS="\"*[,\[\{\}\r\n]+\"*"; 
  FS="\":\"*"; 
} 
// { 
  print $1 " = " $2; 
}

Die Definition des Record Separators ist geblieben. Darunter wird der Field Separator definiert: Ein Anführungszeichen (das steht im JSON-Ergebnis immer vor dem Doppelpunkt und schließt den Feldnamen ab), gefolgt vom Doppelpunkt, gefolgt von einem Anführungszeichen – oder auch nicht, denn bei Zahlen ist der Wert nicht mit Anführungszeichen umschlossen.

Die print-Zeile hat sich entsprechend verändert: Ich lasse nicht mehr die gesamte Zeile ausgeben ($0, siehe vorheriges Script), sondern gebe das erste Feld aus, das awk gefunden hat, was dem JSON-Feldnamen entspricht ($1), gefolgt von einem Gleichheitszeichen zur Trennung, und dann das zweite Feld, was dem JSON-Wert entspricht ($2). Et voilà! Damit ist die hässliche JSON-Fummelei erledigt.

Wer sich etwas näher mit dem Thema befasst, dem rollen sich bei meiner hemdsärmeligen Herangehensweise vermutlich die Fußnägel hoch, denn JSON ist deutlich komplexer und durch meine rein textbasierte Bearbeitung geht fast jede logische Struktur komplett verloren. Für mein Ziel, einfach nur einen Befehlstext herauszufinden, mit dem der CCU-Bot gesteuert werden kann, reicht es aber – zumal alle anderen Lösungen sich nicht direkt in der WebUI programmieren ließen, sondern Add-ons erforderten.

Verwertbares für das WebUI-Script

Die Werteliste, die mein awk-Script bis jetzt ausgibt, ließe sich in der CCU schon halbwegs bearbeiten, aber ich möchte es der Logikschicht möglichst einfach machen und ihr das, was ich suche, mundgerecht servieren.

Ich ändere also mein awk-Script abermals ein wenig ab:

# wget --output-document=- --quiet --no-check-certificate "https://api.telegram.org/bot314321353:AAFKjr29dF940b-aTchoFJ_pb6oZKxzx8Zw/getUpdates?offset=30412545&limit=1" | 
awk 'BEGIN { RS="\"*[,[{}\r\n]+\"*"; FS="\":\"*"; } // { res[$1] = $2; } END { printf res["update_id"] ";" res["id"] ";" res["text"] ; }'
30412545;374629384;welt#

Holla! Was ist denn da passiert?

Auch hier wieder das Script in ausgeschriebener Form:

BEGIN { 
  RS="\"*[,[{}\r\n]+\"*"; 
  FS="\":\"*"; 
} 
// { 
  res[$1] = $2; 
} 
END { 
  printf res["update_id"] ";" res["id"] ";" res["text"] ; 
}

Record- und Field Separator bleiben unverändert. Statt die Feld-Wert-Paare direkt auszugeben, speichere ich sie aber nun in einem Array namens res: Feldname als Index, Wert als, nun ja, Wert. Im END-Block gebe ich dann die Felder, die ich später in meinem Script verwenden möchte, als einfache, durch Semikola getrennte Liste aus:

update_id

Enthält den aktuellen Wert des Feldes, der später in einer Systemvariablen gespeichert werden wird und mit dem ich die Nachrichten der Reihe nach abfragen kann.

id

Das ist meine eigene Chat ID, die ich auch im Telegram-Framework schon herausgefunden und verwendet habe. Anhand dieser ID kann der CCU-Bot später feststellen, dass die Nachricht wirklich von mir kommt.

In Bezug auf Zugriffssicherheit kann man das wohl am ehesten mit einem kleinen Vorhängeschloss vor der offenen Tresortür vergleichen.

text

Erwartungsgemäß: Der Text.

Zum Thema Sicherheit schreibe ich nicht mehr viel, außer immer wieder dieselben Warnungen auszusprechen. Eine Kleinigkeit kann ich awk aber noch erledigen lassen, um zu verhindern, dass der CCU-Bot das Haus in Brand steckt und sich nach Brasilien absetzt, falls ich versehentlich einen Befehl mit ungültigen Zeichen senden sollte.

# wget --output-document=- --quiet --no-check-certificate "https://api.telegram.org/bot314321353:AAFKjr29dF940b-aTchoFJ_pb6oZKxzx8Zw/getUpdates?offset=30412545&limit=1" | 
awk 'BEGIN { RS="\"*[,[{}\r\n]+\"*"; FS="\":\"*"; } match ($2, /^[\/[:alnum:] ]{1,16}$/) { res[$1] = $2; } END { printf res["update_id"] ";" res["id"] ";" res["text"] ; }'
30412545;374629384;welt#

Das Ergebnis ist unverändert, aber die Zeile, in der das Array befüllt wird, hat sich geändert.

BEGIN { 
  RS="\"*[,[{}\r\n]+\"*"; 
  FS="\":\"*"; 
} 
match ($2, /^[\/[:alnum:] ]{1,16}$/) { 
  res[$1] = $2; 
} 
END { 
  printf res["update_id"] ";" res["id"] ";" res["text"] ; 
}

Die Bedingung match() sorgt dafür, dass nur dann Werte in das Array eingetragen werden, wenn diese aus mindestens einem, maximal 16 Buchstaben, Zahlen, Schrägstrichen oder Leerzeichen bestehen. Alle anderen Werte werden verworfen.

Einschränkung erlaubter Zeichen

Mein Vertrauen in die Programmierkünste bei eQ-3 ist begrenzt, daher traue ich der CCU durchaus zu, dass sie bei ungültigen Zeichen in unglücklicher Reihenfolge abstürzt, irgendetwas kaputtgeht oder sonstige Fehler auftreten. Deswegen schränke ich die erlaubten Zeichen ein:

  • Buchstaben und Zahlen für die Texte
  • Schrägstrich für Befehle (die bei Telegram-Bots üblicherweise mit einem / beginnen)
  • Leerzeichen, falls ich mal Befehle mit Parametern basteln möchte.

1-16 Zeichen

Ich begrenze hier die zurückgelieferte Zeichenkette. Zweifellos kann die CCU auch sehr lange Zeichenketten mit Sonderzeichen verarbeiten; zweifellos möchte ich ihr ungern irgendetwas als Input geben, von dem ich nicht im Voraus sicher weiß, dass sie damit umgehen kann. Ein Wort aus maximal 16 Buchstaben und Zahlen? Das schafft sie.

Damit ist die wget-Abfrage mit dem angehängten awk-Script bereit, in ein WebUI-Programm gegossen zu werden. Das ist ein Schritt für sich.

Navigation