Wir schreiben einen Telegram-Bot für lecker Bierchen

Alexander Schoch,programmingjavascripttypescriptgermanprisma
Back

Gehe zu: TypeScript Crash-Kurs, Code Start, Kompletter Code, Kommentare

In diesem Blog Post schauen wir uns an, wie man einen einfachen Telegram-Bot mit TypeScript schreibt. Der Beispiel-Bot in diesem Post soll einer Gruppe erlauben, Trinkevents zu organisieren, wobei ein User ein Event starten kann und Mitglieder können dann abstimmen, ob sie dabei sind oder nicht.

Spezifisch soll der Bot die folgenden Tasks ausführen können:

AS
/help
BB

- /help: Zeige diese Nachricht

- /active: Zeige alle aktiven User

- /enroll: Schliesse dich den aktiven Usern an

- /leave: Verlasse die aktiven User

- /beer: Starte ein Bier-Event

- /ok; /nok; /pending: Stimme über ein aktuelles Bier-Event ab

- /status: Zeige, wer heute dabei ist

AS
/beer
BB

Ich habe es allen aktiven Usern mitgeteilt. Benutze /status, um herauszufinden, wer heute dabei ist.

Auftrag klar? Los geht’s!

Setup des Projekts

Zuerst benötigen wir eine JavaScript runtime. Für dieses Projekt benutze ich Bun. Warum? Weil TypeScript mit Node.js ein PITA ist. Du kannst Bun über die Instruktionen auf ihrer Website installieren.

Als nächstes erstellen wir einen Ordner und initialisieren ein neues Projekt.

mkdir telegram_bot cd telegram_bot bun init

Der bun init Befehl stellt dann ein paar Fragen, und du kannst für alle einfach Enter drücken; die Standards sind tiptop.

Als nächstes installieren wir die Bibliotheken für das Projekt mit bun install. --save-dev sind Abhängigkeiten, die wir nur für die Entwicklung benötigen:

bun install node-telegram-bot-api dayjs @prisma/client bun install --save-dev @types/node-telegram-bot-api prisma

Die Pakete, die wir gerade installiert haben, sind:

Nun müssen wir ein paar Dinge vorbereiten: Eine Datenbank (MariaDB) und ein Telegram Bot API Token.

Datenbanken-Setup

Der einfachste Weg, eine lokale Datenbank zu starten, ist docker. Du kannst Docker hier installieren.

Mit Docker installiert, können wir einen MariaDB-Container mit folgendem Befehl starten:

docker run --name mariadb --env MARIADB_ROOT_PASSWORD=sup3rs3cr3t -p 3306:3306 -d mariadb

Erstelle nun eine neue Datei .env und schreibe die Datenkbankverbindung in diese Datei:

.env
DATABASE_URL = "mysql://root:sup3rs3cr3t@localhost:3306/beerBot"

Jetzt erstellen wir ein Datenbankenschema. Dieses Schema teilt prisma mit, welche Tabellen in der Datenbank vorhanden sein sollen und welche Spalten diese haben sollen. Dafür erstellen wir einen neuen Ordner prisma und schreiben den folgenden Text in eine neue Datei prisma/schema.prisma:

prisma/schema.prisma
// This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env("DATABASE_URL") } model Event { id Int @id @default(autoincrement()) date String @unique creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) creatorId Int votes Vote[] } model User { id Int @id firstName String lastName String isEnrolled Boolean votes Vote[] events Event[] } model Vote { id Int @id @default(autoincrement()) vote String event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) eventId Int user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int }

Dieses Schema konfiguriert die Datenbank, sodass

onDelete: Cascade heisst, dass z.B. ein gelöschtes User-Objekt auch alle Events und Votes dessen löscht. Wenn wir das nicht tun, resultiert das in Orphan Objects (“Waisen”) in der Datenbank, und das ist hässslich.

@unique heisst, dass es stets nur ein Event mit einem spezifischen Datum geben darf, und die Datenbank motzt, falls wir das nicht einhalten. Das erlaubt uns, findUnique anstatt findMany zu verwenden, wenn wir ein Event nach Datum suchen.

Nun migrieren wir das zur Datenbank und geben irgendeinen Namen für die Migration ein.

bunx prisma migrate dev

API Token

Um einen neuen Telegram-Bot zu erhalten, generieren wir Einen mit @BotFather, dem Telegram Bot, um Bots zu magagen.

AS
/newbot
Alright, a new bot. How are we going to call it? Please choose a name for your bot.
AS
Beer Bot
Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot.
AS
my_fancy_beer_bot

Done! Congratulations on your new bot. You will find it at t.me/my_fancy_beer_bot. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this.

Use this token to access the HTTP API:

XXXXXYYYYYZZZZZ

Keep your token secure and store it safely, it can be used by anyone to control your bot.

For a description of the Bot API, see this page: https://core.telegram.org/bots/api

Kopiere das API Token und schreibe es ins vorher erstelle .env:

.env
DATABASE_URL = "mysql://root:sup3rs3cr3t@localhost:3306/beerBot" API_KEY = "XXXXXYYYYYZZZZZ"

Nun kommen wir endlich zum Code.

TypeScript Crash-Kurs

Wenn du dich mit TypeScript schon etwas auskennst, kannst du bei Listener weitermachen.

In diesem Kapitel besprechen wir ein paar einzigartige Features von TypeScript. Es wird erwartet, dass du dich ein Wenig mit anderen Programmiersprachen (z.B. Python) auskennst.

Destructuring

Ein sehr häufiges syntaktisches Zückerli von JavaScript ist object destructuring. Wir verwenden das, um nur einige Elemente eines Objekts zu erhalten:

const obj = { a: 'some text', b: 'some other text', c: 'some more text' }; const {a, b} = obj;

Wir können das ebenfalls verwenden, um ein Objekt in eine Funktion zu geben und dann nur einige Elemente herauszunehmen.

const some_function = ({ a, b }) => a + b; some_function(obj);

Beachte, dass wir eine arrow function mit nur einem Statement ohne die {} schreiben können.

Arrow Functions

Der modene Weg, Funktionen in JavaScript zu definieren, ist die “arrow syntax”:

const function_name = (arguments) => { // do something }

TS

TypeScript ist ein Werkzeug für type checking. Das ist sehr praktisch, um Bugs zu finden, bevor sie auf Production auftreten. In diesem Projekt benutzen wir Types hauptsächlich mit Funktionen:

const function_name = (arguments: ArgumentsType): ReturnType => { // do something }

Um type safety zu überprüfen, können wir den tsc Befehl verwenden:

bunx tsc

async / await

JavaScript ist eine single-threaded, asynchrone Sprache. Das bedeutet, dass Code auf einem Thread läuft (keine Parallelisierung) und dass Funktionsaufrufe, die länger gehen können, auf den Call Stack gesandt werden können. Diese Funktionen werden erst ausgeführt, wenn der Thread gerade nichts tut. So verhindern wir, dass langsame Funktionsaufrufe das ganze Programm langsam machen.

Im Kontext dieses Bots ist die einzige langsame Funktionalität die Kommunikation mit der Datenbank. Diese kann potentiell übers Internet gehen (je nach dem, wo deine Datenkbank gehostet ist) und ist entsprechend (viel) langsamer als normale JavaScript statements. Deswegen stellt prisma seine Funktionalität mittels async Funktionen zu verfügung.

Das bedeutet, dass prisma Funktionen (bzw. alle async Funktionen) stets einen Promise (Versprechen) zurückgeben: Ein Objekt, das sich selbst resolved (“auflöst”), sobald die Funktion fertig ist. Um dieses resolven abzuwarten, können wir das await keyword verwenden. Beachte, dass wir await nur in async Funktionen verwenden können. Der return type einer async Funktion ist Promise<return_type>.

Listener

Zunächst laden wir alle Bibliotheken, die wir benötigen. Die type imports betreffen das Programm selbst nicht, sind aber super fürs Debuggen.

import Bot from "node-telegram-bot-api"; import dayjs from "dayjs"; import { PrismaClient } from "@prisma/client"; import type BotType from "node-telegram-bot-api"; import type { Message } from "node-telegram-bot-api";

Danach instanzieren wir bot und prisma und holen das token aus dem .env.

const token = process.env.API_KEY || ""; const bot = new Bot(token, { polling: true }); const prisma = new PrismaClient();

Beachte das || "" am Ende von const token. Der Grund dafür ist type safety: Es ist möglich, dass process.env.API_KEY undefined ist (z.B. mit einem Tippfehler im .env). In diesem Fall möchten wir nicht, dass token undefined wird, sondern ein leerer String. Entsprechend benutzen wir den || operator (“falls falsy, benutze den Wert von rechts”), um "" zurückzugeben. Das löst dann in der Telegram API einen Fehler aus.

Als nächstes definieren wir ein paar types, die wir später benutzen, und das voteOptions Objekt. Diese Optionen sind diejenigen, die User bei der Abstimmung auswählen können. Der VoteOptionsType definiert, dass voteOptions von einem string indexiert wird und string Werte beinhält.

interface VoteOptionsType { [index: string]: string; } interface CommandArgsType { command: string; args: string[]; prisma: PrismaClient; msg: Message; bot: BotType; } const voteOptions = { ok: "Bin dabei!", nok: "Heute nicht, sorry", pending: "Noch nicht sicher", } as VoteOptionsType;

Nun schreiben wir den Event Listener für den Bot. Dafür hat node-telegram-bot-api eine praktische Funktion:

console.log("Running Telegram Bot. Listening..."); bot.onText(/^\/.+/, async (msg, match) => { console.log( `[debug] ${formatName({ firstName: msg.from?.first_name, lastName: msg.from?.last_name })} ran ${msg.text}`, ); });

Das startet einen Server und führt the Callback-Funktion immer dann aus, wenn eine Nachricht zum Bot der RegEx /^\/.+/ entspricht. Diese RegEx bedeutet “startet mit einem Schrägstrich (^\/), gefolgt von Einem oder mehr Zeichen (.+)”. Falls eine Nachricht dieser RegEx entspricht, wird die Funktion async (msg, match) => {...} ausgeführt. msg ist das Objekt, das alle Infos über die geschickte Nachricht enthält.

Innerhalb der Callback-Funktion trennen wir die Nachricht zunächst nach command und args (hier nicht benutzt, aber sehr praktsich für weitere Funktionalität), indem wir an einem Leerschlag trennen und den ersten Wert als command und den Rest als args interpretieren. Dann legen wir alle nützlichen Variablen, die unser Bot später benutzen kann, in ein neues Objekt args_to_commands. command ist dann der Befehl, args die Argumente nach dem Befehl, prisma das Objekt für Datenbankoperationen, msg das Nachrichten-Objekt und bot das Bot-Objekt, um Nachrichten zu versenden.

msg.text?.split(" ")[0] sagt JS, dass msg.text auch undefined sein kann, und das ? ignoriert den rest des Statements, falls dem so ist. Falls msg.text.split(" ") undefined wird, wird || "" ausgeführt und das statement wird zu einem leeren String.

bot.onText(/^\/.+/, async (msg, match) => { console.log( `[debug] ${formatName({ firstName: msg.from?.first_name, lastName: msg.from?.last_name })} ran ${msg.text}`, ); const command = msg.text?.split(" ")[0] || ""; const args = msg.text?.split(" ").slice(1) || []; const args_to_commands = { command, args, prisma, msg, bot, }; });

Nun schreiben wir ein switch statement, welches Funktionen anhand des User inputs ausführt. Falls der Befehl keinem case entspricht, führen wir den default Block aus.

Die Funktion await createUser(args_to_commands) (später definiert) wird hier aufgerufen, da das user Objekt in fast allen Funktionen verwendet wird. Es macht demnach Sinn, sicherzustellen, dass das user Objekt existiert, bevor wir irgendwelche Funktionen ausführen.

Später implementieren wir async Funktionen für alle Befehle, die der User ausführen kann. Diese Funktionen nehmen args_to_commands als Argument, machen etwas damit und geben einen string zurück. Dieser Rückgabewert wird dann an den User geschickt.

bot.onText(/^\/.+/, async (msg, match) => { console.log( `[debug] ${formatName({ firstName: msg.from?.first_name, lastName: msg.from?.last_name })} ran ${msg.text}`, ); const command = msg.text?.split(" ")[0] || ""; const args = msg.text?.split(" ").slice(1) || []; const args_to_commands = { command, args, prisma, msg, bot, }; await createUser(args_to_commands); let response = ""; switch (true) { case /^\/start/.test(command): response = await start(args_to_commands); break; case /^\/help/.test(command): response = await help(args_to_commands); break; case /^\/enroll/.test(command): response = await enroll(args_to_commands); break; case /^\/leave/.test(command): response = await leave(args_to_commands); break; case /^\/active/.test(command): response = await active(args_to_commands); break; case /^\/beer/.test(command): response = await beer(args_to_commands); break; case /^\/status/.test(command): response = await status(args_to_commands); break; case Object.keys(voteOptions) .map((v) => "/" + v) .includes(command): response = await vote(args_to_commands); break; default: response = await nomatch(args_to_commands); break; } if (response) bot.sendMessage(msg.chat.id, response, { parse_mode: "Markdown" }); });

Einige Helfer-Funktionen

Um unser Leben etwas einfacher zu machen, definieren wir einige Helfer-Funktionen für gängige Tasks.

formatName nimmt einen Vor- und einen Nachnamen und gibt einen String mit beiden Feldern zurück. Der Grund für diese Funktion ist, dass sowohl first_name als auch last_name undefined oder null sein können, und das würde dann hässlich aussehen, weil ein leerer Nachname zu zwei Leerzeichen hintereinander führen kann. (x) => x ist eine Funktion, die sich selbst zurückgibt, und filter wird dieses Resultat dann in einen boolean umwandeln. undefined, null, "" und 0 werden dann zu false umgewandelt.

createUser holt einen User von der Datenkbank. Falls dieser nicht existiert, wird er neu erstellt. Dies stellt sicher, dass das User Objekt für den aktuellen User stets existiert.

getToday gibt das aktuelle Datum als YYYY-MM-DD zurück.

const formatName = ({ firstName, lastName, }: { firstName: string | undefined; lastName: string | undefined; }): string => { return [firstName, lastName].filter((x) => x).join(" "); }; const createUser = async ({ msg, prisma, }: CommandArgsType): Promise<undefined> => { const user = await prisma.user.findUnique({ where: { id: msg.from?.id || 0, }, }); if (user === null) await prisma.user.create({ data: { id: msg.from?.id || 0, firstName: msg.from?.first_name || "", lastName: msg.from?.last_name || "", isEnrolled: true, }, }); }; const getToday = (): string => dayjs().format("YYYY-MM-DD");

/start, /help und nomatch

Die erste Nachricht an einen Bot ist stets /start. In unserem Fall soll dieser Befehl den User informieren, was der Bot macht und wie man ihn bedient. Der Befehl erstellt auch ein User Objekt, da wir await createUser im Listener aufrufen.

const start = async (args: CommandArgsType): Promise<string> => { return `Willkommen beim Bier Bot! Ich habe dich automatisch eingetragen, und du kannst dich jederzeit mit /leave austragen. Hier ist eine Liste von Befehlen: ${await help(args)} ` };

“Warum ist die Formatierung so seltsam”? Wir benutzen Backticks, um einen multi-line string zu erstellen, und alle Einrückungen sind dann sichtbar. Deswegen die fehlende Einrückung.

Die start Funktion ruft help auf, welche wiederum eine Liste von Befehlen zurückgeben soll:

const help = async (args: CommandArgsType): Promise<string> => { return `- /help: Zeige diese Nachricht - /active: Zeige alle aktiven User - /enroll: Schliesse dich den aktiven Usern an - /leave: Verlasse die aktiven User - /beer: Starte ein Bier-Event - /ok; /nok; /pending: Stimme über ein aktuelles Bier-Event ab - /status: Zeige, wer heute dabei ist `; };

Die nomatch Funktion soll den User informieren, dass dessen Befehl nicht gefunden wurde:

const nomatch = async ({ command }: CommandArgsType): Promise<string> => { return `Sorry, ich habe den Befehl ${command} nicht gefunden.`; };

Mit diesen Befehlen können wir das nun ausprobieren. Dafür starten wir den Server mit

bun run index.ts

und schrieben mit dem Bot, indem wir nach @my_fancy_beer_bot (oder wie auch immer du deinen Bot genannt hast) in Telegram suchen.

AS
/start
BB

Willkommen beim Bier Bot! Ich habe dich automatisch eingetragen, und du kannst dich jederzeit mit /leave austragen. Hier ist eine Liste von Befehlen:

- /help: Zeige diese Nachricht

- /active: Zeige alle aktiven User

- /enroll: Schliesse dich den aktiven Usern an

- /leave: Verlasse die aktiven User

- /beer: Starte ein Bier-Event

- /ok; /nok; /pending: Stimme über ein aktuelles Bier-Event ab

- /status: Zeige, wer heute dabei ist

AS
/help
BB

- /help: Zeige diese Nachricht

- /active: Zeige alle aktiven User

- /enroll: Schliesse dich den aktiven Usern an

- /leave: Verlasse die aktiven User

- /beer: Starte ein Bier-Event

- /ok; /nok; /pending: Stimme über ein aktuelles Bier-Event ab

- /status: Zeige, wer heute dabei ist

AS
/wrong_command
BB
Sorry, ich habe den Befehl /wrong_command nicht gefunden.

/enroll und /leave

Diese beiden Funktionen aktualisieren lediglich das User Objekt und setzen isEnrolled auf entweder true oder false.

const enroll = async ({ msg, prisma }: CommandArgsType): Promise<string> => { await prisma.user.update({ where: { id: msg.from?.id || 0, }, data: { isEnrolled: true, }, }); return "gemacht"; }; const leave = async ({ msg, prisma }: CommandArgsType): Promise<string> => { await prisma.user.update({ where: { id: msg.from?.id || 0, }, data: { isEnrolled: false, }, }); return "gemacht"; };
AS
/enroll
BB
gemacht
AS
/leave
BB
gemacht

/active

Dieser Befehl soll alle User in der Datenbank, die isEnrolled = true haben, holen, und diese dann als eine Liste zurückgeben.

const active = async ({ msg, prisma }: CommandArgsType): Promise<string> => { const activeUsers = await prisma.user.findMany({ where: { isEnrolled: true, }, orderBy: [ { firstName: 'asc' } ] }); return activeUsers.map((user) => `- ${formatName(user)}`).join("\n"); };

Die letzte Zeile dieser Funktion wendet die - ${formatName(user)} “funktion” auf alle Elemente an und fügt diese dann mit einer newline (\n) zusammen. Beachte ausserdem das user Argument: Das user Objekt hat, unter anderem, die Felder firstName und lastName. Dieser Code gibt alle Felder des user Objekts an die formatName Funktion weiter, welche dann nur firstName und lastName benutzt und den Rest ignoriert.

orderBy ist eine Option in prisma, das Resultat der Datenbankabfrage in diesem Fall nach firstName in aufsteigender Reihenfolge zu sortieren.

AS
/active
BB

- Alexander Schoch

- Maxime Muster

/beer

Der /beer Befehl soll

Der meiste Code hier sollte mittlerweilen einigermassen vertraut aussehen. Der letzte Teil braucht aber etwas Kontext:

Zuerst filtern wir activeUsers nach “nicht Ersteller eines Events”. Schliesslich möchten wir die Erstellerin nicht benachrichtigen, sie weiss ja schon davon (duh!). Als nächstes loopen wir über alle übrigen User und führen bot.sendMessage darüber aus. Telegram hat ausserdem ein tolles feature namens “Keyboards”. Dieses erlaubt uns, anstelle der normalen Tastatur drei Knöpfe darzustellen: /ok, /nok und /pending. Das drücken eines dieser Knöpfe schickt einfach den Inhalt des Knopfs als Textnachricht.

const beer = async ({ msg, bot, prisma }: CommandArgsType): Promise<string> => { const today = getToday(); // if an event exists already, stop here const existingEvent = await prisma.event.findUnique({ where: { date: today, }, include: { creator: true, }, }); if (existingEvent !== null) return `${formatName(existingEvent.creator)} hat schon ein Event erstellt.`; // create a new event const event = await prisma.event.create({ data: { date: today, creatorId: msg.from?.id || 0, }, include: { creator: true, }, }); // the creator of the event obviously votes /ok await prisma.vote.create({ data: { vote: "ok", eventId: event.id, userId: event.creator.id, }, }); // find all active users to notify const activeUsers = await prisma.user.findMany({ where: { isEnrolled: true, orderBy: [ { firstName: 'asc' } ] }, }); // list all the voteOptions as a string const voteOptionsText = Object.keys(voteOptions) .map((v) => `/${v} - ${voteOptions[v]}`) .join("\n") // full text to send to active users const text = `${formatName(event.creator)} will heute lecker Bierchen trinken. Dabei?\n\n${voteOptionsText}`; // send active users a message activeUsers .filter((user) => user.id !== msg.from?.id || 0) .forEach((user) => { bot.sendMessage(user.id, text, { reply_markup: { keyboard: [Object.keys(voteOptions).map((v) => ({ text: "/" + v }))], }, }); }); return "Ich habe es allen aktiven Usern mitgeteilt. Benutze /status, um herauszufinden, wer heute dabei ist." };

Das sieht die Erstellerin:

AS
/beer
BB
Ich habe es allen aktiven Usern mitgeteilt. Benutze /status, um herauszufinden, wer heute dabei ist.

Und das alle Anderen:

BB

Alexander Schoch will heute lecker Bierchen trinken. Dabei?

/ok - Bin dabei!

/nok - Heute nicht, sorry

/pending - Noch nicht sicher

/status

Lass uns nun rausfinden, wer später beim Bier dabei ist!

Dafür müssen wir das Event mit allen Votes mit der entsprechenden Event Id, sowie den User von jedem Vote von der Datenbank holen. Primsa macht das schon für uns: Mithilfe des include keyword können wir Relations einbinden.

Falls kein Event gefunden wird (prisma gibt null zurück), möchten wir einen Text zurückgeben, der den User benachrichtigt, dass es heute noch kein Event gibt.

Ansonsten zeigen wir alle Stimmen, gruppiert nach voteOption. Dafür filtern wir zuerst die voteOption keys (ok, nok und pending) danach, ob diese überhaupt Votes haben (Zeilen 20-23 unten). Dann loopen wir über die voteOptions, die Votes haben und rendern den Text dieser Option (Zeilen 26+27). Danach filtern wir die Votes nach der aktuellen voteOption, mappen über diese, fügen ein - hinzu und fügen diese mit einer newline zusammen. Falls das für dich verwirrend ist, versuche, die loop-Elemente vor .filter, .map und .join auszudrucken.

const status = async ({ prisma }: CommandArgsType): Promise<string> => { const today = getToday(); const event = await prisma.event.findUnique({ where: { date: today, }, include: { votes: { include: { user: true, }, }, }, }); if (event === null) return "Heute gibt es noch kein Event. Benutze /beer, um ein Neues zu starten!"; const text = Object.keys(voteOptions) .filter( (voteOption) => event.votes.filter((vote) => vote.vote === voteOption).length > 0, ) .map( (voteOption) => voteOptions[voteOption] + ":\n" + event.votes .filter((vote) => vote.vote === voteOption) .map((vote) => `- ${formatName(vote.user)}`) .join("\n"), ) .join("\n"); return text; }
AS
/status
BB

Bin dabei!:

- Alexander Schoch

Heute nicht, sorry:

- Maxime Muster

/ok, /nok und /pending

Die Funktion für diese Befehle, vote, soll diese Logik ausführen:

const vote = async ({ msg, prisma, command, args, bot, }: CommandArgsType): Promise<string> => { const removeSlash = command.replace("/", ""); const today = getToday(); const event = await prisma.event.findUnique({ where: { date: today, }, }); if (event === null) return "Heute gibt es noch kein Event. Benutze /beer, um ein Neues zu starten!"; const votes = await prisma.vote.findMany({ where: { userId: msg.from?.id || 0, eventId: event.id, }, }); if (votes.length === 0) { // create a new vote await prisma.vote.create({ data: { eventId: event.id, userId: msg.from?.id || 0, vote: removeSlash, }, }); } else { // update an existing vote await prisma.vote.update({ where: { id: votes[0].id, }, data: { vote: removeSlash, }, }); } const voteOptionsText = Object.keys(voteOptions) .map((voteOption) => "/" + voteOption) .join("; "); return `gemacht. Du kannst deine Stimme stets mit einem dieser Befehle aktualisieren: ${voteOptionsText}\n\n${await status({ msg, prisma, command, args, bot })}`; };
AS
/ok
BB

gemacht. Du kannst deine Stimme stets mit einem dieser Befehle aktualisieren: /ok; /nok; /pending

Bin Dabei!:

- Alexander Schoch

Heute nicht, sorry:

- Maxime Muster

AS
/nok
BB

gemacht. Du kannst deine Stimme stets mit einem dieser Befehle aktualisieren: /ok; /nok; /pending

Heute nicht, sorry:

- Maxime Muster

- Alexander Schoch

AS
/pending
BB

gemacht. Du kannst deine Stimme stets mit einem dieser Befehle aktualisieren: /ok; /nok; /pending

Heute nicht, sorry:

- Maxime Muster

Noch nicht sicher:

- Alexander Schoch

Das war’s!

Das ist der Code. Dieser Post war eine kleine Demonstration, was man mit der Telegram Bot API machen kann, aber natürlich gibt’s noch viel mehr, was man probieren könnte!

Wie immer ist Feedback stets willkommen (z.B. mit der Kommentarfunktion ganz unten auf der Seite), und teile gerne dein Resultat mit mir!

Kompletter Code

index.ts
import Bot from "node-telegram-bot-api"; import dayjs from "dayjs"; import { PrismaClient } from "@prisma/client"; import type BotType from "node-telegram-bot-api"; import type { Message } from "node-telegram-bot-api"; const token = process.env.API_KEY || ""; const bot = new Bot(token, { polling: true }); const prisma = new PrismaClient(); interface VoteOptionsType { [index: string]: string; } interface CommandArgsType { command: string; args: string[]; prisma: PrismaClient; msg: Message; bot: BotType; } const voteOptions = { ok: "I'm in!", nok: "Not today, sorry", pending: "Not sure yet", } as VoteOptionsType; console.log("Running Telegram Bot. Listening..."); bot.onText(/^\/.+/, async (msg, match) => { console.log( `[debug] ${formatName({ firstName: msg.from?.first_name, lastName: msg.from?.last_name })} ran ${msg.text}`, ); const command = msg.text?.split(" ")[0] || ""; const args = msg.text?.split(" ").slice(1) || []; const args_to_commands = { command, args, prisma, msg, bot, }; await createUser(args_to_commands); let response = ""; switch (true) { case /^\/start/.test(command): response = await start(args_to_commands); break; case /^\/help/.test(command): response = await help(args_to_commands); break; case /^\/enroll/.test(command): response = await enroll(args_to_commands); break; case /^\/leave/.test(command): response = await leave(args_to_commands); break; case /^\/active/.test(command): response = await active(args_to_commands); break; case /^\/beer/.test(command): response = await beer(args_to_commands); break; case /^\/status/.test(command): response = await status(args_to_commands); break; case Object.keys(voteOptions) .map((v) => "/" + v) .includes(command): response = await vote(args_to_commands); break; default: response = await nomatch(args_to_commands); break; } if (response) bot.sendMessage(msg.chat.id, response, { parse_mode: "Markdown" }); }); /* * UTILITY FUNCTIONS */ const formatName = ({ firstName, lastName, }: { firstName: string | undefined; lastName: string | undefined; }): string => { return [firstName, lastName].filter((x) => x).join(" "); }; const createUser = async ({ msg, prisma, }: CommandArgsType): Promise<undefined> => { const user = await prisma.user.findUnique({ where: { id: msg.from?.id || 0, }, }); if (user === null) await prisma.user.create({ data: { id: msg.from?.id || 0, firstName: msg.from?.first_name || "", lastName: msg.from?.last_name || "", isEnrolled: true, }, }); }; const getToday = (): string => dayjs().format("YYYY-MM-DD"); /* * COMMANDS */ const start = async (args: CommandArgsType): Promise<string> => { return `Willkommen beim Bier Bot! Ich habe dich automatisch eingetragen, und du kannst dich jederzeit mit /leave austragen. Hier ist eine Liste von Befehlen: ${await help(args)} `; }; const help = async (args: CommandArgsType): Promise<string> => { return `- /help: Zeige diese Nachricht - /active: Zeige alle aktiven User - /enroll: Schliesse dich den aktiven Usern an - /leave: Verlasse die aktiven User - /beer: Starte ein Bier-Event - /ok; /nok; /pending: Stimme über ein aktuelles Bier-Event ab - /status: Zeige, wer heute dabei ist `; }; const enroll = async ({ msg, prisma }: CommandArgsType): Promise<string> => { await prisma.user.update({ where: { id: msg.from?.id || 0, }, data: { isEnrolled: true, }, }); return "gemacht"; }; const leave = async ({ msg, prisma }: CommandArgsType): Promise<string> => { await prisma.user.update({ where: { id: msg.from?.id || 0, }, data: { isEnrolled: false, }, }); return "gemacht"; }; const active = async ({ msg, prisma }: CommandArgsType): Promise<string> => { const activeUsers = await prisma.user.findMany({ where: { isEnrolled: true, }, orderBy: [ { firstName: 'asc' } ] }); return activeUsers.map((user) => `- ${formatName(user)}`).join("\n"); }; const beer = async ({ msg, bot, prisma }: CommandArgsType): Promise<string> => { const today = getToday(); // if an event exists already, stop here const existingEvent = await prisma.event.findUnique({ where: { date: today, }, include: { creator: true, }, }); if (existingEvent !== null) return `${formatName(existingEvent.creator)} hat schon ein Event erstellt.`; // create a new event const event = await prisma.event.create({ data: { date: today, creatorId: msg.from?.id || 0, }, include: { creator: true, }, }); // the creator of the event obviously votes /ok await prisma.vote.create({ data: { vote: "ok", eventId: event.id, userId: event.creator.id, }, }); // find all active users to notify const activeUsers = await prisma.user.findMany({ where: { isEnrolled: true, }, orderBy: [ { firstName: 'asc' } ] }); // list all the voteOptions as a string const voteOptionsText = Object.keys(voteOptions) .map((v) => `/${v} - ${voteOptions[v]}`) .join("\n") // full text to send to active users const text = `${formatName(event.creator)} will heute lecker Bierchen trinken. Dabei?\n\n${voteOptionsText}`; // send active users a message activeUsers // .filter((user) => user.id !== msg.from?.id || 0) .forEach((user) => { bot.sendMessage(user.id, text, { reply_markup: { keyboard: [Object.keys(voteOptions).map((v) => ({ text: "/" + v }))], }, }); }); return "Ich habe es allen aktiven Usern mitgeteilt. Benutze /status, um herauszufinden, wer heute dabei ist." }; const status = async ({ prisma }: CommandArgsType): Promise<string> => { const today = getToday(); const event = await prisma.event.findUnique({ where: { date: today, }, include: { votes: { include: { user: true, }, }, }, }); if (event === null) return "Heute gibt es noch kein Event. Benutze /beer, um ein Neues zu starten!"; const text = Object.keys(voteOptions) .filter( (voteOption) => event.votes.filter((vote) => vote.vote === voteOption).length > 0, ) .map( (voteOption) => voteOptions[voteOption] + ":\n" + event.votes .filter((vote) => vote.vote === voteOption) .map((vote) => `- ${formatName(vote.user)}`) .join("\n"), ) .join("\n"); return text; }; const vote = async ({ msg, prisma, command, args, bot, }: CommandArgsType): Promise<string> => { const removeSlash = command.replace("/", ""); const today = getToday(); const event = await prisma.event.findUnique({ where: { date: today, }, }); if (event === null) return "Heute gibt es noch kein Event. Benutze /beer, um ein Neues zu starten!"; const votes = await prisma.vote.findMany({ where: { userId: msg.from?.id || 0, eventId: event.id, }, }); if (votes.length === 0) { // create a new vote await prisma.vote.create({ data: { eventId: event.id, userId: msg.from?.id || 0, vote: removeSlash, }, }); } else { // update an existing vote await prisma.vote.update({ where: { id: votes[0].id, }, data: { vote: removeSlash, }, }); } const voteOptionsText = Object.keys(voteOptions) .map((voteOption) => "/" + voteOption) .join("; "); return `gemacht. Du kannst deine Stimme stets mit einem dieser Befehle aktualisieren: ${voteOptionsText}\n\n${await status({ msg, prisma, command, args, bot })}`; }; const nomatch = async ({ command }: CommandArgsType): Promise<string> => { return `Sorry, ich habe den Befehl ${command} nicht gefunden.`; };

Comments

Source Code

Subscribe via RSS.

MIT 2025 © Alexander Schoch.