Wir schreiben einen Telegram-Bot für lecker Bierchen
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:
- Er soll ein paar Standardbefehle wie /start oder /help unterstützen
- Er soll Usern erlauben, sich mittels /enroll oder /leave einzutragen (aktiv werden) oder sich auszutragen
- Er soll Usern erlauben, eine Liste der Aktiven mittels /active zu erhalten
- Er soll Usern erlauben, ein neues Bier-Event mittels /beer zu starten
- Er soll Usern erlauben, sich einem Bier-Event anzuschliessen (mit /ok), nicht anzuschliessen (mit /nok) oder sich unsicher zu sein (mit /pending)
- Er soll Usern erlauben, den aktuellen Status des Events mittels /status einzusehen (wer hat sich angeschlossen?)
- /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
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:
node-telegram-bot-api
: Ein Wrapper um die Telegram Bot API. Das macht es einfach, mit Telegram über TypeScript zu kommunizieren.dayjs
: Wir werden ein paar Daten (mehrzahl von Datum) benötigen, und das macht man am besten mitdayjs
anstatt des JSDateTime
.prisma
: Wir müssen einige Infos in der Datenbank speichern, undprisma
ist ein super Tool, um mit Datenbanken zu arbeiten.
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:
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
:
// 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
- Ein
Event
eineid
(automatisch generiert), einendate
String (z.B. 2025-02-02), einencreator
als UserId und eine Liste von Votes für ein Event hält - Ein
User
eineid
(das ist die Telegram ID), einenfirstName
und einenlastName
, einen booleanisEnrolled
(true
wenn aktiv undfalse
sonst) und Listen vonVote
undEvent
(alle Events, die ein User mit /beer gestartet hat) hält - Ein
Vote
eineid
(automatisch generiert), einenvote
(ok
,nok
oderpending
), einevent
als Event Id und einenUser
als User Id hält.
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.



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
:
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.
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
- /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
/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";
};
/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.
- Alexander Schoch
- Maxime Muster
/beer
Der /beer Befehl soll
- schauen, ob es schon ein Event für heute gibt. Falls ja: return.
- ein neues Event erstellen
- /ok für den User abstimmen. Natürlich sind sie dabei!
- alle aktiven User finden
- einen Text generieren, der an alle geschickt wird
- diesen Text an alle senden
- den Text für den User zurückgeben
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:
Und das alle Anderen:
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;
}
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:
- finde ein Event für heute. Falls kein Event existiert, benachrichtige den User
- finde alle Votes mit der heutigen Event Id und der User Id
- falls ein solches Vote existiert, aktualisieren wir dieses
- ansonsten erstellen wir ein Neues
- gib die
voteOptions
, etwas Text und den aktuellen Status zurück
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 })}`;
};
gemacht. Du kannst deine Stimme stets mit einem dieser Befehle aktualisieren: /ok; /nok; /pending
Bin Dabei!:
- Alexander Schoch
Heute nicht, sorry:
- Maxime Muster
gemacht. Du kannst deine Stimme stets mit einem dieser Befehle aktualisieren: /ok; /nok; /pending
Heute nicht, sorry:
- Maxime Muster
- Alexander Schoch
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
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
Subscribe via RSS.
MIT 2025 © Alexander Schoch.