Skip to content
Snippets Groups Projects

RITS

Merged Lukas Liesenberg requested to merge RITS into master
Files
17
src/bot.ts 0 → 100644
+ 607
0
import dotenv from 'dotenv'
import TelegramBot from 'node-telegram-bot-api'
import moment from 'moment-timezone'
import { refreshAccessToken, requestCanteens, requestMenu, capitalize, mealTypeContains, unMarkdownify } from './utils.js'
import { MealType, type MenuResponse, type MenuOfTheDayResponse, type MealResponse, SideDishType } from './responses.js'
import * as cron from 'node-cron'
import * as fs from 'fs'
import * as rd from 'readline'
moment.tz.setDefault('Europe/Berlin')
moment.locale('de-DE')
dotenv.config()
const bot: TelegramBot = new TelegramBot(process.env.TELEGRAM_TOKEN ?? '', { polling: true })
let accessToken: string = ''
let awaitingCanteenInput: boolean = false
let savedFilter: string = ''
let savedDate: Date = new Date(moment.now())
let cooldown: boolean = false
const currencyFormatter = Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
})
const mealTypeEmojis: Record<MealType, string> = {
[MealType.Pig]: '🐷',
[MealType.Poultry]: '🐔',
[MealType.Beef]: '🐮',
[MealType.Fish]: '🐟',
[MealType.Vegetarian]: '🌱',
[MealType.Vegan]: '🥑',
[MealType.Lamb]: '🐑',
[MealType.Deer]: '🦌'
}
const possibleCommands: string[] = [
'heute',
'morgen',
'tag',
'alles',
'vegan',
'vegetarisch',
'license',
'help'
]
const helpString = '*Hallo!* Ich bin der Mensabot.\n\n' +
'Kommandos: ' + possibleCommands.map(c => `/${c}`).join(', ') + '\n' +
'Mensa: Direkt spezifizieren mit Abkürzung, z.B. `aca` für Academica\n' +
'Filter: Auswahl aus: `(' + [...Object.keys(mealTypeEmojis), 'Meat', 'Fleisch', 'Vegetarisch'].join(' | ') + ')` \n' +
'Beispiele:\n' +
'`/heute aca`\n' +
'`/morgen ahorn vegan`\n' +
'`/alles vita vegan`\n' +
'`/vegan aca` (Abk. für `/heute aca vegan`)\n' +
'`/morgen`'
const possibleCommandsRegex: RegExp = new RegExp(`\\/(${possibleCommands.join('|')})((?: +[a-zA-ZäöüÄÖÜß]+)*)`, 'g')
accessToken = await refreshAccessToken()
const canteens: string[] = (await requestCanteens(accessToken))
.map(c => c
.replace('ue', 'ü')
.replace('strasse', 'straße'))
const canteensWithPrefix: string[] = canteens.map(c => {
switch (c) {
case 'Templergraben':
return 'Bistro Templergraben'
case 'Esstw':
return 'Esstw'
default:
return `Mensa ${c}`
}
})
bot.onText(possibleCommandsRegex, (msg, match): void => {
void (async (): Promise<void> => {
awaitingCanteenInput = false
if (match !== null) {
switch (match[1]) {
case 'heute':
await handleDateCommand('heute', match, msg)
break
case 'morgen':
await handleDateCommand('morgen', match, msg)
break
case 'vegan':
await handleFilterCommand('vegan', match, msg)
break
case 'vegetarisch':
await handleFilterCommand('vegetarisch', match, msg)
break
case 'alles':
await handleDateCommand('alles', match, msg)
break
case 'license':
await bot.sendMessage(msg.chat.id, 'This bot is FOSS! https://git.rwth-aachen.de/yum/mensa_bot')
break
case 'help':
await bot.sendMessage(msg.chat.id, helpString, { parse_mode: 'Markdown', reply_to_message_id: msg.message_id })
break
}
}
})()
})
const requestCanteenName = async (msg: TelegramBot.Message): Promise<void> => {
await bot.sendMessage(msg.chat.id, 'Bitte Mensanamen auswählen (oder einfach beim nächsten mal dazu schreiben :))',
{
reply_to_message_id: msg.message_id,
reply_markup: {
keyboard: canteensWithPrefix.map(c => ([{ text: c }])),
one_time_keyboard: true
}
})
}
bot.onText(new RegExp(`${canteensWithPrefix.join('|')}`), (msg, match): void => {
void (async (): Promise<void> => {
if (awaitingCanteenInput && match !== null) {
awaitingCanteenInput = false
const canteenReconverted: string = match[0].replace('Mensa ', '').replace('Bistro ', '').replace('ü', 'ue').replace('ß', 'ss')
if (savedFilter !== '') {
await sendMenu(msg, canteenReconverted, savedDate, savedFilter)
savedFilter = ''
savedDate = new Date(moment.now())
} else {
await sendMenu(msg, canteenReconverted, savedDate)
savedDate = new Date(moment.now())
}
}
})()
})
bot.on('callback_query', cbq => {
void (async (): Promise<void> => {
if (!cooldown) {
cooldown = true
const today = new Date(moment.now())
switch (cbq.data) {
case 'ahorn':
try {
if (cbq.message?.text === unMarkdownify(await getMessageString('Ahornstrasse', today))) {
await bot.answerCallbackQuery(cbq.id, { text: 'Ahorn wird bereits angezeigt.' })
} else {
try {
await bot.editMessageText(await getMessageString('Ahornstrasse', today), { parse_mode: 'Markdown', reply_markup: cbq.message?.reply_markup, message_id: cbq.message?.message_id, chat_id: cbq.message?.chat.id })
} catch (e) {
console.error('Error while editing Channel message', e)
await bot.answerCallbackQuery(cbq.id, { text: 'Bitte erneut probieren.' })
}
}
} catch (e) {
console.error('Error while editing Channel message', e)
await bot.answerCallbackQuery(cbq.id, { text: 'Bitte erneut probieren.' })
}
break
case 'vita':
try {
if (cbq.message?.text === unMarkdownify(await getMessageString('Vita', today))) {
await bot.answerCallbackQuery(cbq.id, { text: 'Vita wird bereits angezeigt.' })
} else {
try {
await bot.editMessageText(await getMessageString('Vita', today), { parse_mode: 'Markdown', reply_markup: cbq.message?.reply_markup, message_id: cbq.message?.message_id, chat_id: cbq.message?.chat.id })
} catch (e) {
console.error('Error while editing Channel message', e)
await bot.answerCallbackQuery(cbq.id, { text: 'Bitte erneut probieren.' })
}
}
} catch (e) {
console.error('Error while editing Channel message', e)
await bot.answerCallbackQuery(cbq.id, { text: 'Bitte erneut probieren.' })
}
break
case 'aca':
try {
if (cbq.message?.text === unMarkdownify(await getMessageString('Academica', today))) {
await bot.answerCallbackQuery(cbq.id, { text: 'Academica wird bereits angezeigt.' })
} else {
try {
await bot.editMessageText(await getMessageString('Academica', today), { parse_mode: 'Markdown', reply_markup: cbq.message?.reply_markup, message_id: cbq.message?.message_id, chat_id: cbq.message?.chat.id })
} catch (e) {
console.error('Error while editing Channel message', e)
await bot.answerCallbackQuery(cbq.id, { text: 'Bitte erneut probieren.' })
}
}
} catch (e) {
console.error('Error while editing Channel message', e)
await bot.answerCallbackQuery(cbq.id, { text: 'Bitte erneut probieren.' })
}
break
}
cooldown = false
}
})()
})
bot.on('polling_error', err => {
console.error('Polling Error', err)
})
const sendMenu = async (msg: TelegramBot.Message | null, canteen: string, date: Date, filter?: string, markup?: TelegramBot.InlineKeyboardMarkup): Promise<TelegramBot.Message> => {
const messageString = await getMessageString(canteen, date, filter)
if (msg !== null) {
return await bot.sendMessage(msg.chat.id, messageString, { parse_mode: 'Markdown', reply_to_message_id: msg.message_id })
} else if (markup !== undefined) {
return await bot.sendMessage(process.env.CHANNEL_ID as TelegramBot.ChatId, messageString, { parse_mode: 'Markdown', reply_markup: markup })
} else {
return await bot.sendMessage(process.env.CHANNEL_ID as TelegramBot.ChatId, messageString, { parse_mode: 'Markdown' })
}
}
const getMessageString = async (canteen: string, date: Date, filter?: string): Promise<string> => {
let menus: MenuOfTheDayResponse[] = []
if (date.getTime() === 0) {
menus = (await requestMenu(accessToken, canteen))
} else {
menus = (await requestMenu(accessToken, canteen, date))
}
let markdownStrings: string = ''
let firstString: boolean = true
for (const menu of menus) {
let relevantMainDishes: MenuResponse[] = []
let sideDishes: MealResponse[] = []
date = moment(menu.day, 'YYYY-MM-DD').toDate()
if (menu !== undefined) {
const mainDishesGroupedByType: Record<'Vegan' | 'Vegetarian' | 'Else', MenuResponse[]> = {
Vegan: [],
Vegetarian: [],
Else: []
}
sideDishes = menu.sideDishes
// check for StW errors
if (menu.menus.every(m => m.mealTypes.length === 0)) {
markdownStrings += constructString(canteen, date, [], [], firstString) + '\n\n\n'
firstString = false
continue
}
for (const singleMenu of menu.menus) {
switch (true) {
case singleMenu.mealTypes.includes(MealType.Vegan):
mainDishesGroupedByType.Vegan.push(singleMenu)
break
case singleMenu.mealTypes.includes(MealType.Vegetarian):
mainDishesGroupedByType.Vegetarian.push(singleMenu)
break
default:
mainDishesGroupedByType.Else.push(singleMenu)
break
}
}
if (filter !== undefined && filter !== '') {
switch (filter) {
case 'vegan':
relevantMainDishes = mainDishesGroupedByType.Vegan
break
case 'vegetarian':
case 'vegetarisch':
relevantMainDishes = [...mainDishesGroupedByType.Vegetarian, ...mainDishesGroupedByType.Vegan]
break
case 'beef':
relevantMainDishes = mainDishesGroupedByType.Else.filter(d => d.mealTypes.includes(MealType.Beef))
break
case 'pig':
relevantMainDishes = mainDishesGroupedByType.Else.filter(d => d.mealTypes.includes(MealType.Pig))
break
case 'poultry':
relevantMainDishes = mainDishesGroupedByType.Else.filter(d => d.mealTypes.includes(MealType.Poultry))
break
case 'fish':
relevantMainDishes = mainDishesGroupedByType.Else.filter(d => d.mealTypes.includes(MealType.Fish))
break
case 'lamb':
relevantMainDishes = mainDishesGroupedByType.Else.filter(d => d.mealTypes.includes(MealType.Lamb))
break
case 'deer':
relevantMainDishes = mainDishesGroupedByType.Else.filter(d => d.mealTypes.includes(MealType.Deer))
break
case 'fleisch':
case 'meat':
relevantMainDishes = mainDishesGroupedByType.Else
break
}
} else {
relevantMainDishes = [...mainDishesGroupedByType.Vegan, ...mainDishesGroupedByType.Vegetarian, ...mainDishesGroupedByType.Else]
}
}
const markdownString: string = constructString(canteen, date, relevantMainDishes, sideDishes, firstString)
markdownStrings += markdownString + '\n\n\n'
firstString = false
}
if (menus.length === 0) {
return constructString(canteen, date, [], [], firstString)
}
return markdownStrings
}
const constructString = (canteen: string, date: Date, mainDishes: MenuResponse[], sideDishes: MealResponse[], firstString: boolean): string => {
let message: string = ''
const numberOfDishes: number = mainDishes.length
if (firstString) {
// We need to reconvert to humean readable name again
switch (canteen) {
case 'Templergraben':
canteen = 'Bistro Templergraben'
break
case 'Esstw':
canteen = 'Esstw'
break
default:
canteen = `Mensa ${canteen.replace('ue', 'ü').replace('strasse', 'straße')}`
break
}
message += `*Plan für ${canteen}*\n\n`
}
message += `_${moment(date).format('dddd')}, ${moment(date).format('DD.MM.YYYY')}_\n\n`
const mainDishesroupedByCategory: Record<string, MenuResponse[]> = mainDishes
.reduce< Record<string, MenuResponse[]>>((prev, cur) => {
if (prev[cur.category] === undefined) {
prev[cur.category] = [cur]
} else {
prev[cur.category].push(cur)
}
return prev
}, {})
if (mainDishes.some(d => d.mealTypes.includes(MealType.Vegan))) {
message += '═══《 🥑 𝐕𝐞𝐠𝐚𝐧 🥑 》═══\n\n'
let groupedByCategory = Object.values(mainDishesroupedByCategory)
.sort((a, b) => a[0].category.localeCompare(b[0].category))
.filter(d => d
.find(dish => dish.mealTypes
.includes(MealType.Vegan)) !== undefined)
for (const group of groupedByCategory) {
groupedByCategory[groupedByCategory.indexOf(group)] = group.filter(d => d.mealTypes.includes(MealType.Vegan))
}
groupedByCategory = groupedByCategory.filter(g => g.length !== 0)
for (const group of groupedByCategory) {
// Academica Express 0€ price check
if (group[0].category === 'Express' && group[0].price === null && group[0].meals[0].name === mainDishes.filter(d => d.category === 'Klassiker')[0].meals[0].name) {
message += `_${group[0].category}_ — ${currencyFormatter.format(mainDishes.filter(d => d.category === 'Klassiker')[0].price).replace(/\s/g, '')}\n`
} else {
message += `_${group[0].category}_ — ${currencyFormatter.format(group[0].price).replace(/\s/g, '')}\n`
}
for (const dish of group) {
let dishString: string = '*' + dish.meals.reduce((prev, cur) => prev + cur.name + ' | ', '').replace(/ \| $/gm, '')
if (dishString.includes(' |')) {
dishString = dishString.replace(' |', '* |')
} else {
dishString += '*'
}
message += dishString + ' 🥑\n'
}
message += '\n'
}
mainDishes = mainDishes.filter(d => !d.mealTypes.includes(MealType.Vegan))
}
if (mainDishes.some(d => d.mealTypes.includes(MealType.Vegetarian))) {
message += '═══《 🌱 𝐕𝐞𝐠𝐞𝐭𝐚𝐫𝐢𝐬𝐜𝐡 🌱 》═══\n\n'
let groupedByCategory = Object.values(mainDishesroupedByCategory)
.sort((a, b) => a[0].category.localeCompare(b[0].category))
.filter(d => d
.find(dish => !dish.mealTypes
.includes(MealType.Vegan) &&
dish.mealTypes.includes(MealType.Vegetarian)) !== undefined)
for (const group of groupedByCategory) {
groupedByCategory[groupedByCategory.indexOf(group)] = group.filter(d => d.mealTypes.includes(MealType.Vegetarian) && !d.mealTypes.includes(MealType.Vegan))
}
groupedByCategory = groupedByCategory.filter(g => g.length !== 0)
for (const group of groupedByCategory) {
// Academica Express 0€ price check
if (group[0].category === 'Express' && group[0].price === null && group[0].meals[0].name === mainDishes.filter(d => d.category === 'Klassiker')[0].meals[0].name) {
message += `_${group[0].category}_ — ${currencyFormatter.format(mainDishes.filter(d => d.category === 'Klassiker')[0].price).replace(/\s/g, '')}\n`
} else {
message += `_${group[0].category}_ — ${currencyFormatter.format(group[0].price).replace(/\s/g, '')}\n`
}
for (const dish of group) {
let dishString: string = '*' + dish.meals.reduce((prev, cur) => prev + cur.name + ' | ', '').replace(/ \| $/gm, '')
if (dishString.includes(' |')) {
dishString = dishString.replace(' |', '* |')
} else {
dishString += '*'
}
message += dishString + ' 🌱\n'
}
message += '\n'
}
mainDishes = mainDishes.filter(d => !d.mealTypes.includes(MealType.Vegetarian))
}
if (mainDishes.length !== 0) {
// custom emojis that include everything "else"
const relevantEmojis: string[] = [...new Set(mainDishes.map(d => d.mealTypes.map(m => mealTypeEmojis[m])).flat())]
message += `═══《 ${relevantEmojis.join(' ')} 𝐀𝐧𝐝𝐞𝐫𝐞𝐬 ${relevantEmojis.join(' ')} 》═══\n\n`
let groupedByCategory = Object.values(mainDishesroupedByCategory)
.sort((a, b) => a[0].category.localeCompare(b[0].category))
.filter(d => d
.find(dish => !dish.mealTypes
.includes(MealType.Vegan) &&
!dish.mealTypes.includes(MealType.Vegetarian)) !== undefined)
for (const group of groupedByCategory) {
groupedByCategory[groupedByCategory.indexOf(group)] = group.filter(d => !d.mealTypes.includes(MealType.Vegan) && !d.mealTypes.includes(MealType.Vegetarian))
}
groupedByCategory = groupedByCategory.filter(g => g.length !== 0)
for (const group of groupedByCategory) {
// Academica Express 0€ price check
if (group[0].category === 'Express' && group[0].price === null && group[0].meals[0].name === mainDishes.filter(d => d.category === 'Klassiker')[0].meals[0].name) {
message += `_${group[0].category}_ — ${currencyFormatter.format(mainDishes.filter(d => d.category === 'Klassiker')[0].price).replace(/\s/g, '')}\n`
} else {
message += `_${group[0].category}_ — ${currencyFormatter.format(group[0].price).replace(/\s/g, '')}\n`
}
for (const dish of group) {
let dishString: string = '*' + dish.meals.reduce((prev, cur) => prev + cur.name + ' | ', '').replace(/ \| $/gm, '')
if (dishString.includes(' |')) {
dishString = dishString.replace(' |', '* |')
} else {
dishString += '*'
}
message += dishString + ` ${dish.mealTypes.map(m => mealTypeEmojis[m]).join(' ')}\n`
}
message += '\n'
}
}
if (sideDishes.length !== 0 && numberOfDishes === 0) {
message += 'Dein gewählter Filter hat leider keine Ergebnisse geliefert! :(\n\n'
}
if (sideDishes.length === 0 && numberOfDishes === 0) {
message += '*geschlossen*'
} else {
message += `_Hauptbeilagen:_ ${sideDishes.filter(d => d.sideDishType === SideDishType.Main).map(d => `*${d.name}*`).join(' oder ')}`
message += `\n_Nebenbeilagen:_ ${sideDishes.filter(d => d.sideDishType === SideDishType.Secondary).map(d => `*${d.name}*`).join(' oder ')}`
}
return message
}
const handleDateCommand = async (command: string, match: RegExpExecArray, msg: TelegramBot.Message): Promise<void> => {
switch (command) {
case 'heute':
savedDate = new Date(moment.now())
break
case 'morgen':
savedDate = new Date(moment(moment.now()).add(1, 'day').toISOString())
break
case 'alles':
savedDate = new Date(0)
break
}
if (match[2] === '') {
awaitingCanteenInput = true
await requestCanteenName(msg)
} else if (mealTypeContains(match[2]
.trim()
.split(' ')[0]) || ['vegetarisch', 'fleisch', 'meat'].includes(match[2]
.trim()
.split(' ')[0].toLowerCase())) {
awaitingCanteenInput = true
savedFilter = match[2].trim().toLowerCase()
await requestCanteenName(msg)
} else if (canteens
.map(c => c.toLowerCase())
.filter(c => c.includes(match[2].trim().split(' ')[0].toLowerCase()))
.length !== 0) {
const canteen: string = canteens
.map(c => c.toLowerCase())
.filter(c => c.includes(match[2].trim().split(' ')[0].toLowerCase()))[0]
.replace('ü', 'ue')
.replace('ß', 'ss')
if (match[2].trim().split(' ')[1] !== undefined && (
mealTypeContains(match[2]
.trim()
.split(' ')[1]) || ['vegetarisch', 'fleisch', 'meat'].includes(match[2]
.trim()
.split(' ')[1].toLowerCase()))) {
savedFilter = match[2].trim().split(' ')[1].toLowerCase()
} else if (match[2].trim().split(' ')[1] !== undefined) {
// TODO: unrecognized filter
}
if (savedFilter !== '') {
await sendMenu(msg, capitalize(canteen), savedDate, savedFilter)
savedFilter = ''
} else {
await sendMenu(msg, capitalize(canteen), savedDate)
}
}
}
const handleFilterCommand = async (command: string, match: RegExpExecArray, msg: TelegramBot.Message): Promise<void> => {
if (match[2] === '') {
awaitingCanteenInput = true
await requestCanteenName(msg)
} else if (canteens
.map(c => c.toLowerCase())
.filter(c => c.includes(match[2].trim().split(' ')[0].toLowerCase()))
.length !== 0) {
const canteen: string = canteens
.map(c => c.toLowerCase())
.filter(c => c.includes(match[2].trim().split(' ')[0].toLowerCase()))[0]
.replace('ü', 'ue')
.replace('ß', 'ss')
await sendMenu(msg, capitalize(canteen), savedDate, command)
}
}
const sendChannelMessage = async (): Promise<void> => {
const today: Date = new Date(moment.now())
const ahorn = await requestMenu(accessToken, 'Ahornstrasse', today)
const vita = await requestMenu(accessToken, 'Vita', today)
const aca = await requestMenu(accessToken, 'Academica', today)
if (aca.length !== 0 && ahorn.length !== 0 && vita.length !== 0) {
const markup: TelegramBot.InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: 'Ahorn', callback_data: 'ahorn' },
{ text: 'Academica', callback_data: 'aca' },
{ text: 'Vita', callback_data: 'vita' }],
[{
text: 'More options via DM\'s',
url: `tg://resolve?domain=${(await bot.getMe()).username}`
}]]
}
const msg: TelegramBot.Message = await sendMenu(null, 'Ahornstrasse', today, '', markup)
fs.appendFile('./remember_ids.txt', msg.message_id.toString() + '\n', err => {
if (err != null) { throw err }
}
)
}
}
const clearMarkup = async (): Promise<void> => {
const reader = rd.createInterface(fs.createReadStream('./remember_ids.txt'))
const data: string[] = []
for await (const line of reader) {
data.push(line.trim())
}
for (const id of data) {
await bot.editMessageReplyMarkup({ inline_keyboard: [] }, { message_id: Number(id), chat_id: process.env.CHANNEL_ID })
}
fs.writeFile('./remember_ids.txt', '', err => {
if (err != null) { throw err }
})
}
cron.schedule('0 8 * * *', async () => { await sendChannelMessage() }, { runOnInit: false, timezone: 'Europe/Berlin' })
cron.schedule('0 0 * * *', async () => { await clearMarkup() }, { runOnInit: false, timezone: 'Europe/Berlin' })
Loading