diff --git a/Dockerfile b/Dockerfile index 709b457..13c25c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-slim +FROM python:3.10-slim LABEL version="2021" \ description="Vaalilakanabot" \ @@ -9,6 +9,6 @@ COPY requirements.txt requirements.txt RUN pip install -r requirements.txt COPY assets ./assets -COPY vaalilakanabot2021.py vaalilakanabot2021.py +COPY vaalilakanabot2022.py vaalilakanabot2022.py -CMD ["python3", "vaalilakanabot2021.py"] \ No newline at end of file +CMD ["python3", "vaalilakanabot2022.py"] diff --git a/README.md b/README.md index 5375972..cb57ea9 100644 --- a/README.md +++ b/README.md @@ -1,46 +1 @@ -# Vaalilakanabot - -Telegram-botti, joka vaalien aikaan ylläpitää listausta ehdolle asettuneista henkilöistä ja ilmoittaa uusista postauksista killan Discourse-pohjaisella keskustelufoorumilla [Φrumilla](https://fiirumi.fyysikkokilta.fi). - -## Ominaisuudet -- Ilmoittaa chatteissa, joihin botti on lisätty, aina kun fiirumille on tullut uusi postaus -- Botin admin-käyttäjä voi ylläpitää sähköistä vaalilakanaa -- Jauhistelu - -## Käyttöönotto -- asenna `python-telegram-bot`-kirjasto (versio >=12) ja muut tarvittavat kirjastot -- lisää Bot Fatherilta saatava `VAALILAKANABOT_TOKEN` ympäristönmuuttujaksi käyttöjärjestelmään. -- täydennä ADMIN_CHAT_ID koodiin (halutun ryhmän id:n saa esimerkiksi lisäämällä botin `@RawDataBot` haluttuun ryhmään) -- Päivitä TOPIC_LIST_URL ja QUESTION_LIST_URL -muuttujat koodiin. Katso [Discoursen dokumentaatio](https://docs.discourse.org/#tag/Categories/paths/~1c~1{id}.json/get) oikeanlaisen URL:n asettamiseksi. -- `$ python vaalilakanabot2019.py` -- lisää botti relevantteihin keskusteluryhmiin - -## Running the bot with Docker -- create copies of the example_*.json files with such names that the "example_" part is removed. -- create `bot.env` where `VAALILAKANABOT_TOKEN` and `ADMIN_CHAT_ID` env variables are stored -- Update TOPIC_LIST_URL ja QUESTION_LIST_URL -variables in the code. See [Discourse documentation](https://docs.discourse.org/#tag/Categories/paths/~1c~1{id}.json/get) for formatting. -- ```bash - # Use this to run the development container (from dev branch in GitHub) - docker-compose -f docker-compose.yml --profile dev up -d - - # Use this to run the production container (from master branch in GitHub) - docker-compose -f docker-compose.yml --profile prod up -d - ``` - -## Komennot -Botti tukee seuraavia komentoja: -- `/start` Rekisteröi ryhmän botin tiedotuskanavaksi ja ryhmää saa botilta ilmoituksia. -- `/jauhis` Näytää vaaliaiheisen kuvan. -- `/lakana` Näytää vaalien ehdokastilanteen. - -Admin-chatissa seuraavat komennot ovat käytössä: -- `/lisaa` Lisää ehdokkaan vaalilakanaan. -- `/lisaa_fiirumi` Lisää ehdokkaan fiirumipostauksen vaalilakanaan. -- `/poista` Poistaa ehdokkaan lakanasta. -- `/valittu` Merkitsee vaalilakanassa ehdokkaan valituksi virkaan. -- `/tiedota` Julkaisee uuden merkinnän vaalilakanassa. - -## Lisätietoa -Lisää telegram-boteista voi lukea esimerkiksi [Kvantti I/19 s.22-25](https://kvantti.ayy.fi/blog/wp-content/uploads/2019/03/kvantti-19-1-nettiin.pdf). - -Botin tekivät [Einari Tuukkanen](https://github.com/EinariTuukkanen) ja Uula Ollila. +Kopio fyysikkokillan vaalilakanabotista github.com/fyysikkokilta/vaalilakanabot diff --git a/assets/jaauh1.png b/assets/jaauh1.png new file mode 100644 index 0000000..0e345ad Binary files /dev/null and b/assets/jaauh1.png differ diff --git a/assets/jaauh2.png b/assets/jaauh2.png new file mode 100644 index 0000000..55375ac Binary files /dev/null and b/assets/jaauh2.png differ diff --git a/docker-compose.yml b/docker-compose.yml index 876d2cb..0fdecb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,22 +2,21 @@ version: '3.4' services: bot: build: . - image: ghcr.io/fyysikkokilta/vaalilakanabot:master volumes: - ./data:/bot/data:rw - ./logs:/bot/logs:rw env_file: - bot.env - profiles: - - prod + # profiles: + # - prod + restart: always - dev-bot: - build: . - image: ghcr.io/fyysikkokilta/vaalilakanabot:dev - volumes: - - ./data:/bot/data:rw - - ./logs:/bot/logs:rw - env_file: - - bot.env - profiles: - - dev \ No newline at end of file + # dev-bot: + # build: . + # volumes: + # - ./data:/bot/data:rw + # - ./logs:/bot/logs:rw + # env_file: + # - bot.env + # profiles: + # - dev diff --git a/vaalilakanabot2022.py b/vaalilakanabot2022.py new file mode 100644 index 0000000..9e6f127 --- /dev/null +++ b/vaalilakanabot2022.py @@ -0,0 +1,589 @@ +import json +import os +import time +import random +from glob import glob + +import logging +import requests + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import Updater, MessageHandler, CommandHandler, \ + Filters, ConversationHandler, CallbackContext, CallbackQueryHandler + +TOKEN = os.environ['VAALILAKANABOT_TOKEN'] +ADMIN_CHAT_ID = os.environ['ADMIN_CHAT_ID'] + +BASE_URL = 'https://fiirumi.fyysikkokilta.fi' + +# TODO: update these to correspond current year discussion board +TOPIC_LIST_URL = f'{BASE_URL}/c/vaalipeli-2022-esittelyt/l/latest.json' +QUESTION_LIST_URL = f'{BASE_URL}/c/vaalipeli-2022-kysymykset/l/latest.json' + +BOARD = ['Puheenjohtaja', 'Varapuheenjohtaja', 'Rahastonhoitaja', 'Viestintävastaava', + 'IE', 'Hupimestari', 'Yrityssuhdevastaava', 'Kv-vastaava', 'Opintovastaava', + 'Fuksikapteeni'] + +OFFICIALS = ['ISOvastaava', 'Jatkuvuustoimikunnan puheenjohtaja', 'Excumestari', + 'Lukkarimestari', 'Kvantin päätoimittaja'] + +SELECTING_POSITION_CLASS, SELECTING_POSITION, TYPING_NAME, CONFIRMING = range(4) + +channels = [] +vaalilakana = {} +last_applicant = None +fiirumi_posts = [] +question_posts = [] + +logger = logging.getLogger('vaalilakanabot') +logger.setLevel(logging.INFO) + +log_path = os.path.join('logs', 'vaalilakanabot.log') +fh = logging.FileHandler(log_path) +fh.setLevel(logging.INFO) + +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +fh.setFormatter(formatter) +logger.addHandler(fh) + +with open('data/vaalilakana.json', 'r') as f: + data = f.read() + vaalilakana = json.loads(data) + +logger.info('Loaded vaalilakana: %s', vaalilakana) + +with open('data/channels.json', 'r') as f: + data = f.read() + channels = json.loads(data) + +logger.info('Loaded channels: %s', channels) + +with open('data/fiirumi_posts.json', 'r') as f: + data = f.read() + fiirumi_posts = json.loads(data) + +logger.info('Loaded fiirumi posts: %s', fiirumi_posts) + +with open('data/question_posts.json', 'r') as f: + data = f.read() + question_posts = json.loads(data) + +logger.info('Loaded question posts: %s', fiirumi_posts) + +updater = Updater(TOKEN, use_context=True) + + +def _save_data(filename, content): + with open(filename, 'w') as fp: + fp.write(json.dumps(content)) + + +def _vaalilakana_to_string(lakana): + output = '' + output += '---------------Raati---------------\n' + # Hardcoded to maintain order instead using dict keys + for position in BOARD: + output += f'{position}:\n' + for applicant in lakana[position]: + link = applicant['fiirumi'] + selected = applicant['valittu'] + if selected: + if link: + output += f'- {applicant["name"]} (valittu)\n' + else: + output += f'- {applicant["name"]} (valittu)\n' + else: + if link: + output += f'- {applicant["name"]}\n' + else: + output += f'- {applicant["name"]}\n' + + output += '\n' + output += '----------Toimihenkilöt----------\n' + for position in OFFICIALS: + output += f'{position}:\n' + for applicant in lakana[position]: + link = applicant['fiirumi'] + selected = applicant['valittu'] + if selected: + if link: + output += f'- {applicant["name"]} (valittu)\n' + else: + output += f'- {applicant["name"]} (valittu)\n' + else: + if link: + output += f'- {applicant["name"]}\n' + else: + output += f'- {applicant["name"]}\n' + + output += '\n' + return output + + +def parse_fiirumi_posts(context=updater.bot): + try: + page_fiirumi = requests.get(TOPIC_LIST_URL) + logger.debug(page_fiirumi) + page_question = requests.get(QUESTION_LIST_URL) + topic_list_raw = page_fiirumi.json() + logger.debug(str(topic_list_raw)) + question_list_raw = page_question.json() + topic_list = topic_list_raw['topic_list']['topics'] + question_list = question_list_raw['topic_list']['topics'] + + logger.debug(topic_list) + except KeyError as e: + logger.error("The topic and question lists cannot be found. Check URLs. Got error %s", e) + return + except Exception as e: + logger.error(e) + return + + for topic in topic_list: + t_id = topic['id'] + title = topic['title'] + slug = topic['slug'] + if str(t_id) not in fiirumi_posts: + new_post = { + 'id': t_id, + 'title': title, + 'slug': slug, + } + fiirumi_posts[str(t_id)] = new_post + _save_data('data/fiirumi_posts.json', fiirumi_posts) + _announce_to_channels( + f'Uusi postaus Vaalipeli-palstalla!\n{title}\n{BASE_URL}/t/{slug}/{t_id}' + ) + + for question in question_list: + t_id = question['id'] + title = question['title'] + slug = question['slug'] + if str(t_id) not in question_posts: + new_question = { + 'id': t_id, + 'title': title, + 'slug': slug, + } + question_posts[str(t_id)] = new_question + _save_data('data/question_posts.json', question_posts) + _announce_to_channels( + f'Uusi kysymys Fiirumilla!\n{title}\n{BASE_URL}/t/{slug}/{t_id}' + ) + + +def _announce_to_channels(message): + for cid in channels: + try: + updater.bot.send_message(cid, message, parse_mode='HTML') + time.sleep(0.5) + except Exception as e: + logger.error(e) + continue + + +def remove_applicant(update, context): + try: + chat_id = update.message.chat.id + if str(chat_id) == str(ADMIN_CHAT_ID): + text = update.message.text.replace('/poista', '').strip() + params = text.split(',') + + try: + position = params[0].strip() + name = params[1].strip() + except Exception as e: + updater.bot.send_message( + chat_id, + 'Virheelliset parametrit - /poista , ' + ) + raise Exception('Invalid parameters') from e + + if position not in vaalilakana: + updater.bot.send_message( + chat_id, + f'Tunnistamaton virka: {position}', + parse_mode='HTML' + ) + raise Exception(f'Unknown position {position}') + + found = None + for applicant in vaalilakana[position]: + if name == applicant['name']: + found = applicant + break + + if not found: + updater.bot.send_message( + chat_id, + f'Hakijaa ei löydy {name}', + parse_mode='HTML' + ) + raise Exception(f'Applicant not found: {name}') + + vaalilakana[position].remove(found) + _save_data('data/vaalilakana.json', vaalilakana) + global last_applicant + last_applicant = None + + updater.bot.send_message( + chat_id, + 'Poistettu:\n{position}: {name}'.format( + **found + ), + parse_mode='HTML' + ) + except Exception as e: + logger.error(e) + + +def add_fiirumi_to_applicant(update, context): + try: + chat_id = update.message.chat.id + if str(chat_id) == str(ADMIN_CHAT_ID): + text = update.message.text.replace('/lisaa_fiirumi', '').strip() + params = text.split(',') + + try: + position = params[0].strip() + name = params[1].strip() + thread_id = params[2].strip() + except Exception as e: + updater.bot.send_message( + chat_id, + 'Virheelliset parametrit - /lisaa_fiirumi , , ' + ) + raise Exception('Invalid parameters') from e + + if position not in vaalilakana: + updater.bot.send_message( + chat_id, + f'Tunnistamaton virka: {position}', + parse_mode='HTML' + ) + raise Exception(f'Unknown position {position}') + + if thread_id not in fiirumi_posts: + updater.bot.send_message( + chat_id, + f'Fiirumi-postausta ei löytynyt annetulla id:llä: {thread_id}', + parse_mode='HTML' + ) + raise Exception(f'Unknown thread {thread_id}') + + found = None + for applicant in vaalilakana[position]: + if name == applicant['name']: + found = applicant + fiirumi = f'{BASE_URL}/t/{fiirumi_posts[thread_id]["slug"]}/{fiirumi_posts[thread_id]["id"]}' + applicant['fiirumi'] = fiirumi + break + + if not found: + updater.bot.send_message( + chat_id, + f'Hakijaa ei löydy {name}', + parse_mode='HTML' + ) + raise Exception(f'Applicant not found: {name}') + + _save_data('data/vaalilakana.json', vaalilakana) + global last_applicant + last_applicant = None + + updater.bot.send_message( + chat_id, + 'Lisätty Fiirumi:\n{position}: {name}'.format( + fiirumi, + **found + ), + parse_mode='HTML' + ) + except Exception as e: + logger.error(e) + + +def add_applicant(update: Update, context: CallbackContext) -> None: + """Add an applicant. This command is for admin use.""" + keyboard = [[ + InlineKeyboardButton("Raatiin", callback_data='board'), + InlineKeyboardButton("Toimihenkilöksi", callback_data='official'), + ]] + + reply_markup = InlineKeyboardMarkup(keyboard) + + chat_id = update.message.chat.id + if str(chat_id) == str(ADMIN_CHAT_ID): + update.message.reply_text('Mihin rooliin henkilö lisätään?', reply_markup=reply_markup) + return SELECTING_POSITION_CLASS + else: + update.message.reply_text('Et oo admin :(((') + return None + + +def generate_positions(position_class): + keyboard = [] + for position in position_class: + keyboard.append( + [InlineKeyboardButton(position, callback_data=position)] + ) + return keyboard + + +def select_position_class(update: Update, context: CallbackContext) -> int: + """Parses the CallbackQuery and updates the message text.""" + query = update.callback_query + query.answer() + chat_data = context.chat_data + logger.debug(str(chat_data)) + + # CallbackQueries need to be answered, even if no notification to the user is needed + # Some clients may have trouble otherwise. See https://core.telegram.org/bots/api#callbackquery + if query.data == '1': + logger.debug('Raati') + keyboard = InlineKeyboardMarkup(generate_positions(BOARD)) + query.edit_message_reply_markup(keyboard) + return SELECTING_POSITION + elif query.data == '2': + logger.debug('Toimihenkilö') + keyboard = InlineKeyboardMarkup(generate_positions(OFFICIALS)) + query.edit_message_reply_markup(keyboard) + return SELECTING_POSITION + else: + return SELECTING_POSITION_CLASS + + +def select_board_position(update: Update, context: CallbackContext) -> int: + query = update.callback_query + query.answer() + keyboard = InlineKeyboardMarkup(generate_positions(BOARD)) + query.edit_message_reply_markup(keyboard) + return SELECTING_POSITION + + +def select_official_position(update: Update, context: CallbackContext) -> int: + query = update.callback_query + query.answer() + keyboard = InlineKeyboardMarkup(generate_positions(OFFICIALS)) + query.edit_message_reply_markup(keyboard) + return SELECTING_POSITION + + +def register_position(update: Update, context: CallbackContext) -> int: + """Parses the CallbackQuery and updates the message text.""" + query = update.callback_query + chat_data = context.chat_data + + # CallbackQueries need to be answered, even if no notification to the user is needed + # Some clients may have trouble otherwise. See https://core.telegram.org/bots/api#callbackquery + query.answer() + query.edit_message_text(text=f"Hakija rooliin: {query.data}\nKirjoita hakijan nimi vastauksena tähän viestiin") + chat_data['new_applicant_position'] = query.data + return TYPING_NAME + + +def enter_applicant_name(update: Update, context: CallbackContext) -> int: + """Stores the info about the user and ends the conversation.""" + chat_data = context.chat_data + logger.debug(chat_data) + name = update.message.text + logger.debug(name) + try: + chat_id = update.message.chat.id + position = chat_data['new_applicant_position'] + chat_data['new_applicant_name'] = name + + new_applicant = {'name': name, 'position': position, 'fiirumi': '', 'valittu': False} + + vaalilakana[position].append(new_applicant) + _save_data('data/vaalilakana.json', vaalilakana) + global last_applicant + last_applicant = new_applicant + + updater.bot.send_message( + chat_id, + 'Lisätty:\n{position}: {name}.\n\nLähetä tiedote komennolla /tiedota'.format( + **new_applicant + ), + parse_mode='HTML' + ) + except Exception as e: + # TODO: Return to role selection + logger.error(e) + return ConversationHandler.END + + +def add_selected_tag(update, context): + try: + chat_id = update.message.chat.id + if str(chat_id) == str(ADMIN_CHAT_ID): + text = update.message.text.replace('/valittu', '').strip() + params = text.split(',') + + try: + position = params[0].strip() + name = params[1].strip() + except Exception as e: + updater.bot.send_message( + chat_id, + 'Virheelliset parametrit - /valittu , ' + ) + raise Exception from e + + if position not in vaalilakana: + updater.bot.send_message( + chat_id, + f'Tunnistamaton virka: {position}', + parse_mode='HTML' + ) + raise Exception(f'Unknown position {position}') + + found = None + for applicant in vaalilakana[position]: + if name == applicant['name']: + found = applicant + applicant['valittu'] = True + break + + if not found: + updater.bot.send_message( + chat_id, + f'Hakijaa ei löydy {name}', + parse_mode='HTML' + ) + raise Exception(f'Applicant not found: {name}') + + _save_data('data/vaalilakana.json', vaalilakana) + global last_applicant + last_applicant = None + + updater.bot.send_message( + chat_id, + 'Hakija valittu:\n{position}: {name}'.format( + **found + ), + parse_mode='HTML' + ) + except Exception as e: + logger.error(e) + + +def show_vaalilakana(update, context): + try: + chat_id = update.message.chat.id + updater.bot.send_message( + chat_id, + _vaalilakana_to_string(vaalilakana), + parse_mode='HTML', disable_web_page_preview=True + ) + except Exception as e: + logger.error(e) + + +def register_channel(update, context): + try: + chat_id = update.message.chat.id + if chat_id not in channels: + channels.append(chat_id) + _save_data('data/channels.json', channels) + print(f'New channel added {chat_id}', update.message) + updater.bot.send_message( + chat_id, + 'Rekisteröity Vaalilakanabotin tiedotuskanavaksi!' + ) + except Exception as e: + logger.error(e) + + +def announce_new_applicant(update, context): + try: + chat_id = update.message.chat.id + if str(chat_id) == str(ADMIN_CHAT_ID): + global last_applicant + if last_applicant: + _announce_to_channels( + 'Uusi nimi vaalilakanassa!\n{position}: {name}'.format( + **last_applicant + ) + ) + last_applicant = None + except Exception as e: + logger.error(e) + + +def jauhis(update, context): + try: + chat_id = update.message.chat.id + with open('assets/jauhis.png', 'rb') as photo: + updater.bot.send_sticker(chat_id, photo) + except Exception as e: + logger.warning("Error in sending Jauhis %s", e) + + +def jauh(update, context): + try: + chat_id = update.message.chat.id + with open('assets/jauh.png', 'rb') as photo: + updater.bot.send_sticker(chat_id, photo) + except Exception as e: + logger.warning("Error in sending Jauh %s", e) + +def jaauh(update, context): + img = random.choice(glob('assets/jaauh*.png')) + try: + chat_id = update.message.chat.id + with open(img, 'rb') as photo: + updater.bot.send_sticker(chat_id, photo) + except Exception as e: + logger.warning("Error in sending Jaauh %s", e) + +def error(update, context): + """Log Errors caused by Updates.""" + logger.warning('Update "%s" caused error "%s"', update, context.error) + + +def cancel(update, context): + chat_data = context.chat_data + chat_data.clear() + + +def main(): + jq = updater.job_queue + jq.run_repeating(parse_fiirumi_posts, interval=60, first=0, context=updater.bot) + + dp = updater.dispatcher + + dp.add_handler(CommandHandler('valittu', add_selected_tag)) + dp.add_handler(CommandHandler('lisaa_fiirumi', add_fiirumi_to_applicant)) + dp.add_handler(CommandHandler('poista', remove_applicant)) + dp.add_handler(CommandHandler('lakana', show_vaalilakana)) + dp.add_handler(CommandHandler('tiedota', announce_new_applicant)) + dp.add_handler(CommandHandler('start', register_channel)) + dp.add_handler(CommandHandler('jauhis', jauhis)) + dp.add_handler(CommandHandler('jauh', jauh)) + dp.add_handler(CommandHandler('jaauh', jaauh)) + + conv_handler = ConversationHandler( + entry_points=[CommandHandler('lisaa', add_applicant)], + states={ + SELECTING_POSITION_CLASS: [ + CallbackQueryHandler(select_board_position, pattern='^board$'), + CallbackQueryHandler(select_official_position, pattern='^official$') + ], + SELECTING_POSITION: [ + CallbackQueryHandler(register_position) + ], + TYPING_NAME: [MessageHandler(Filters.text & (~Filters.command), enter_applicant_name)], + }, + fallbacks=[CommandHandler('cancel', cancel), CommandHandler('lisaa', add_applicant)], + ) + + dp.add_handler(conv_handler) + + dp.add_error_handler(error) + updater.start_polling() + updater.idle() + + +if __name__ == "__main__": + main()