diff --git a/README.md b/README.md index d4d027f..718c0ac 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,28 @@ Ein Jabber-Bot, der Meldungen vom BBK an konfigurierte Accounts weiterleitet. Die Daten stammen von [hier](https://warnung.bund.de/meldungen) und umfassen das IHP, den DWD, das MoWaS und die BIWAPP. +JANINE ist ein Modul, welches im Kontext des Bot-Frameworks [mira](https://git.polynom.me/PapaTutuWawa/mira) +verwendet wird. + ## Dependencies JANINE benötigt -- `aioxmpp>=0.11.0` +- `mira>=0.1.0` - `requests>=2.24.0` ## Verwendung -Instaliert wird JANINE per `python setup.py install`. +```toml +# [...] -Die Datei `janine.example.conf` erklärt alle notwendigen -Einstellungen. +[[modules]] +name = "mira.modules.janine.janine" +# Diese Vier sind die gültigen Quellen +sources = ["IHP", "DWD", "MOWAS", "BIWAPP"] -Um den Bot zu starten, verwendet man einfach `janine`. Als optionaler -Parameter kann noch der Pfad zur Konfiguration übergeben werden. -Standardmäßig wird versucht `/etc/janine/janine.conf` zu laden. +# [...] +``` ## Bugs diff --git a/janine.example.conf b/janine.example.conf deleted file mode 100644 index 6a54c1c..0000000 --- a/janine.example.conf +++ /dev/null @@ -1,14 +0,0 @@ -[General] -IHP = y # Hochwasserwarnungen -DWD = y # Unwetterwarnungen -MOWAS = y # Gefahrendurchsagen -BIWAPP = y # Warnmeldungen -Timeout=630 # Zeit in Sekunden nach welcher nach neuen Warnungen geschaut wird -DataDir = /etc/janine/data # Verzeichnis für persistente Daten -SameDomain = True # Soll der Bot nur auf Nachrichten von der selben Domain antworten - -[Bot] -Avatar = /etc/janine/avatar.png # Bot Avatar (Optional) -JID = janine@some.server.xmpp # Bot Account -Password = super_secret_password # Bot Passwort -Status = Gibt dir im Notfall Bescheid # Statusnachricht (Optional) diff --git a/janine/__init__.py b/janine/__init__.py deleted file mode 100644 index e4f9adc..0000000 --- a/janine/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -''' -This file is part of JANINE. - -JANINE is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -JANINE is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with JANINE. If not, see . -''' diff --git a/janine/janine.py b/janine/janine.py deleted file mode 100644 index 839f389..0000000 --- a/janine/janine.py +++ /dev/null @@ -1,406 +0,0 @@ -''' -This file is part of JANINE. - -JANINE is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -JANINE is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with JANINE. If not, see . -''' - -import os -import sys -import json -import configparser -import logging -import asyncio -from collections import namedtuple -from optparse import OptionParser -from stat import S_IRUSR, S_IWUSR - -import aioxmpp -from aioxmpp.structs import PresenceShow - -import requests - -from janine.utils import find_one, make_msg, format_warning -from janine.sources import sources_from_config, MiscDataSources - -log = logging.getLogger('janine') -log.setLevel(logging.INFO) -log.addHandler(logging.StreamHandler()) - -Warning_ = namedtuple('Warning_', ['id', - 'sent', - 'effective_from', - 'expires', - 'urgency', - 'sender', - 'headline', - 'description', - 'instruction', - 'landkreise']) - - -def stub_warning(id_): - ''' - Returns a stubbed warning for loading warnings from disk. - The only real attribute is the @id_ . - ''' - return Warning_(id=id_, - sent='', - effective_from='', - expires='', - urgency='', - sender='', - headline='', - description='', - instruction='', - landkreise=[]) - -def to_warning(data): - ''' - Returns a Warning given the raw data - ''' - info = find_one(lambda x: 'headline' in x.keys(), data['info']) - return Warning_(id=data['identifier'], - sent=data['sent'], - # Not all items have to have those - effective_from=info.get('effective', 'N/A'), - # Not all items have to have those - expires=info.get('expires', 'N/A'), - urgency=info['urgency'], - # Not all items have to have those - sender=info.get('senderName', 'N/A'), - headline=info['headline'], - description=info['description'], - instruction=info.get('instruction', ''), - landkreise=get_landkreise(data)) - -def get_landkreise(data): - ''' - Returns the list of Landkreise relevant to the warning in @data - ''' - info = find_one(lambda e: 'area' in e.keys(), data['info']) - geocode = find_one(lambda e: 'geocode' in e.keys(), info['area']) - - # Note: Some items may have multiple Landkreise - return list(map(lambda e: e['valueName'], geocode['geocode'])) - -def parse_data(text): - ''' - Reads the remote response, parses it and returns a list of warnings. - ''' - data = json.loads(text) - return [to_warning(el) for el in data] - -class WarningBot: - ''' - This class represents the actual bot. The only things - to be done is call connect() after creating an instance. - ''' - def __init__(self, config_file): - self._warnings0 = [] - self._warnings1 = [] - self._client = None - self._warn_clients = {} - self._refresh_timeout = 630 # 15min - - # Configuration stuff - self._data_dir = '' - self._client_store = '' - self._warning_store = '' - - self._load_config(config_file) - - async def connect(self): - ''' - Starts the "event loop" of the bot - ''' - self._client = aioxmpp.PresenceManagedClient( - self._jid, - aioxmpp.make_security_layer(self._password)) - - log.debug('Connecting to server') - async with self._client.connected(): - log.info('Client connected to server') - - # In case you want a nice avatar - if self._avatar: - log.debug('Setting avatar') - with open(self._avatar, 'rb') as avatar_file: - image_data = avatar_file.read() - - avatar_set = aioxmpp.avatar.AvatarSet() - avatar_set.add_avatar_image('image/png', image_bytes=image_data) - await (self._client.summon(aioxmpp.avatar.AvatarService) - .publish_avatar_set(avatar_set)) - log.info('Avatar set') - - # Set some presence information - self._client.set_presence( - aioxmpp.PresenceState(available=True, - show=PresenceShow.CHAT), - self._status) - - # Enable Carbons - await self._client.summon(aioxmpp.CarbonsClient).enable() - log.info('Message carbons enabled') - - # Register the message handler - self._client.stream.register_message_callback( - aioxmpp.MessageType.CHAT, - None, - self._handle_message) - log.info('Message handler registered') - - # Start our fetch-send loop - # NOTE: Originally, I wanted to use a cronjob and - # signal.signal(...) for this but you can't - # use async in event handlers - loop = asyncio.get_event_loop() - periodic = loop.create_task(self._periodic_requests()) - log.info('Periodic ticker started') - await periodic - - def __is_message_valid(self, msg): - ''' - Returns True on messages we want to handle. False otherwise. - ''' - if msg.type_ != aioxmpp.MessageType.CHAT: - return False - - if not msg.body: - return False - - if msg.from_.domain != self._jid.domain and self._same_domain: - return False - - return True - - def _handle_message(self, msg): - # Handle cases we don't want to deal with - if not self.__is_message_valid(msg): - return - - # Send a deliverability receipt - receipt = aioxmpp.mdr.compose_receipt(msg) - self._client.enqueue(receipt) - - cmd_parts = str(msg.body.any()).split(' ') - cmd = cmd_parts[0].lower() - - if cmd == 'subscribe': - # Do we have a landkreis? - if len(cmd_parts) < 2: - self._client.enqueue(make_msg( - to=msg.from_, - body='Du hast keinen Landkreis angegeben')) - return - - # Check if the entered Landkreis is valid - landkreis = ' '.join(cmd_parts[1:]) - if not landkreis in self._channels: - self._client.enqueue(make_msg( - to=msg.from_, - body='Der angegebene Landkreis ist ungültig')) - return - - if landkreis not in self._warn_clients.keys(): - self._warn_clients[landkreis] = [] - self._warn_clients[landkreis].append(str(msg.from_.bare())) - - self._client.enqueue(make_msg( - to=msg.from_, - body=f'Du erhälst nun Nachrichten zu {landkreis} von mir')) - - with open(self._client_store, 'w') as clients_file: - clients_file.write(json.dumps(self._warn_clients)) - - # Send all known warnings for the landkreis to the user - for warning in self._warnings0: - if landkreis in warning.landkreise: - body = format_warning(warning) - self._client.enqueue(make_msg( - to=msg.from_, - body=body)) - - elif cmd == 'unsubscribe': - # Do we have a landkreis? - if len(cmd_parts) < 2: - self._client.enqueue(make_msg( - to=msg.from_, - body='Du hast keinen Landkreis angegeben')) - return - - landkreis = ' '.join(cmd_parts[1:]) - if landkreis not in self._warn_clients: - self._client.enqueue(make_msg( - to=msg.from_, - body=f'Du hast {landkreis} nicht abonniert')) - return - - if str(msg.from_.bare()) in self._warn_clients[landkreis]: - filter_ = lambda x: x != str(msg.from_.bare()) - self._warn_clients[landkreis] = list(filter(filter_, - self._warn_clients[landkreis])) - self._client.enqueue(make_msg( - to=msg.from_, - body=f'Du erhälst keine Nachrichten zu {landkreis} mehr von mir')) - - if len(self._warn_clients[landkreis]) == 0: - del self._warn_clients[landkreis] - else: - self._client.enqueue(make_msg( - to=msg.from_, - body=f'Du hast {landkreis} nicht abonniert')) - elif cmd == 'help': - body = '''Verfügbare Befehle: - -subscribe - Abonniere einen Landkreis -unsubscribe - Entferne das Abonnement zu einem Landkreis -help - Gebe diese Hilfe aus''' - self._client.enqueue(make_msg( - to=msg.from_, - body=body)) - else: - self._client.enqueue(make_msg( - to=msg.from_, - body='Diesen Befehl kenne ich nicht... Mit "help" kannst du alle Befehle sehen, die ich kenne.')) - - async def _periodic_requests(self): - ''' - "Executes" every self._refresh_timeout seconds to fetch all - configured warnings and send them to the users, if there are - any new ones. - ''' - while True: - log.debug('Refreshing warning list') - await self._fetch_warnings() - - # Flush the warnings to disk - ids = [x.id for x in self._warnings0] - with open(self._warnings_file, 'w') as warnings_file: - warnings_file.write(json.dumps(ids)) - - await asyncio.sleep(self._refresh_timeout) - - async def _fetch_warnings(self): - ''' - Fetches all warnings and tries to find new ones - to send notifications. - ''' - self._warnings1 = self._warnings0 - self._warnings0 = [] - for source in self._sources: - try: - req = requests.get(source) - self._warnings0 += parse_data(req.text) - except urllib3.exceptions.MaxRetryError: - log.warn('Connection timeout for request to %s', source) - continue - - # Find new warnings and send the new ones - ids = [x.id for x in self._warnings1] - for warning in self._warnings0: - if warning.id in ids: - continue - - # We need to use a set as a warning can apply to more than one - # Landkreis - if set(warning.landkreise).intersection(self._warn_clients.keys()): - body = format_warning(warning) - for landkreis in warning.landkreise: - for to in self._warn_clients.get(landkreis, []): - await self._client.send(make_msg( - aioxmpp.JID.fromstr(to), - body)) - - def _load_config(self, config_file): - # Load config - config = configparser.ConfigParser() - config.read(config_file) - - # Configure sources - self._sources = sources_from_config(config) - self._data_dir = config['General'].get('DataDir', '/etc/janine/data') - self._refresh_timeout = int(config['General']['Timeout']) - self._same_domain = config['General'].get('SameDomain', 'True') == 'True' - - # Persistent data - ## Subscribed clients - self._client_store = os.path.join(self._data_dir, 'clients.json') - if os.path.exists(self._client_store): - with open(self._client_store, 'r') as clients_file: - self._warn_clients = json.loads(clients_file.read()) - log.info('Clients read from disk') - else: - with open(self._client_store, 'w') as clients_file: - clients_file.write('{}') - os.chmod(self._client_store, S_IRUSR | S_IWUSR) - log.info('Clients file created with 0600') - - ## Warnings - self._warnings_file = os.path.join(self._data_dir, 'warnings.json') - if os.path.exists(self._warnings_file): - with open(self._warnings_file, 'r') as warnings_file: - self._warnings0 = list(map(stub_warning, - json.loads(warnings_file.read()))) - log.info('Warnings read from disk') - - ## Landkreise - self._channels = [] - self._channel_file = os.path.join(self._data_dir, 'channels.json') - if not os.path.exists(self._channel_file): - log.info('Requesting search channels') - req = requests.get(MiscDataSources.channels()) - channels = json.loads(req.text) - self._channels = [channels[key].get('NAME', '') for key in channels] - - try: - with open(self._channel_file, 'w') as channel_file: - channel_file.write(json.dumps(self._channels)) - except Exception as err: - log.error('Failed to cache channel data:') - log.error(str(err)) - else: - with open(self._channel_file, 'r') as channel_file: - self._channels = json.loads(channel_file.read()) - log.info('Search channels read from disk') - - # Bot Config - self._jid = aioxmpp.JID.fromstr(config['Bot']['JID']) - self._password = config['Bot']['Password'] - self._avatar = config['Bot'].get('Avatar', None) - self._status = config['Bot'].get('Status', 'Warnt dich vor Katastrophen') - -def main(): - ''' - Main function. - ''' - parser = OptionParser() - parser.add_option('-d', '--debug', action='store_true', dest='debug', default=False) - (options, args) = parser.parse_args() - - if options.debug: - log.setLevel(logging.DEBUG) - - if len(args) != 0: - config_file = args[0] - else: - config_file = '/etc/janine/janine.conf' - - bot = WarningBot(config_file) - loop = asyncio.get_event_loop() - loop.run_until_complete(bot.connect()) - loop.close() - -if __name__ == '__main__': - main() diff --git a/mira/modules/janine/__init__.py b/mira/modules/janine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/janine/utils.py b/mira/modules/janine/helpers.py similarity index 82% rename from janine/utils.py rename to mira/modules/janine/helpers.py index 902b9b9..d8e95ea 100644 --- a/janine/utils.py +++ b/mira/modules/janine/helpers.py @@ -14,22 +14,8 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with JANINE. If not, see . ''' - import datetime -import aioxmpp - -def make_msg(to, body): - ''' - Wrapper for creating a message object to enqueue or send. - ''' - msg = aioxmpp.Message( - type_=aioxmpp.MessageType.CHAT, - to=to) - msg.body[None] = body - - return msg - def pad_time_component(c): ''' If we have 12:08, it gets turned into 12:8, so we need to pad @@ -83,11 +69,3 @@ def find_one(func, array): if func(e): return e return None - -def find_all(func, array): - ''' - Utility function - - Return all elements in array for which func returns True. - ''' - return [e for e in array if func(e)] diff --git a/mira/modules/janine/janine.py b/mira/modules/janine/janine.py new file mode 100644 index 0000000..72b9c6b --- /dev/null +++ b/mira/modules/janine/janine.py @@ -0,0 +1,170 @@ +''' +This file is part of JANINE. + +JANINE is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +JANINE is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with JANINE. If not, see . +''' +import logging +import asyncio +from urllib3.exceptions import MaxRetryError + +from mira.module import BaseModule +from mira.modules.janine.sources import MiscDataSources, WarningSources +from mira.modules.janine.warnings import parse_data, stub_warning +from mira.modules.janine.helpers import format_warning + +import requests +import aioxmpp + +logger = logging.getLogger('mira.modules.janine.janine') + +NAME = 'janine' + +class JanineModule(BaseModule): + __instance = None + + @staticmethod + def get_instance(base, **kwargs): + if JanineModule.__instance == None: + JanineModule(base, **kwargs) + + return JanineModule.__instance + + def __init__(self, base, **kwargs): + if JanineModule.__instance != None: + raise Exception('Trying to init singleton twice') + + super().__init__(base, **kwargs) + JanineModule.__instance = self + + self._subcommand_table = { + 'subscribe': self._subscribe, + 'unsubscribe': self._unsubscribe, + 'hilfe': self._help, + '*': self._any + } + self._channels = self._stm.get_data('channels') + if not self._channels: + # TODO: Move out of the constructor. Perform this asynchronously, + # and just refuse to process commands before we're done. Start the + # request loop afterwards. + logger.info('Channels do not exist. Downloading...') + req = requests.get(MiscDataSources.channels()) + channels = req.json() + self._channels = [channels[key].get('NAME', '') for key in channels] + self._stm.set_data('channels', self._channels) + logger.info('Done') + + self._warnings0 = list(map(stub_warning, self._stm.get_data('warnings'))) + self._warnings1 = [] + self._refresh_timeout = self.get_option('refresh_timeout', 15 * 60) + self._sources = list(map(WarningSources.source_by_name, self._config['sources'])) + + loop = asyncio.get_event_loop() + periodic = loop.create_task(self._periodic_ticker()) + + async def _periodic_ticker(self): + ''' + "Executes" every self._refresh_timeout seconds to fetch all + configured warnings and send them to the users, if there are + any new ones. + ''' + while True: + logger.debug('Refreshing warning list') + await self._request_warnings() + + self._stm.set_data('warnings', + [x.id for x in self._warnings0]) + + await asyncio.sleep(self._refresh_timeout) + + async def _request_warnings(self): + ''' + Requests warnings from all configured warning sources and + sends new ones as notifications + ''' + self._warnings1 = self._warnings0 + self._warnings0 = [] + for source in self._sources: + try: + req = requests.get(source) + self._warnings0 += parse_data(req.json()) + except MaxRetryError: + logger.warn('Connection timeout for request to %s', source) + continue + + # Find new warnings and send them out + ids = [x.id for x in self._warnings1] + for warning in self._warnings0: + if warning.id in ids: + continue + + for jid, _ in self._sum.get_subscriptions_for_keywords(warning.landkreise): + body = format_warning(warning) + self.send_message(aioxmpp.JID.fromstr(jid), body) + + async def _subscribe(self, cmd, msg): + if len(cmd) < 2: + self.send_message(msg.from_, 'Du hast keinen Landkreis angegeben') + return + + landkreis = ' '.join(cmd[1:]) + if not landkreis in self._channels: + self.send_message(msg.from_, 'Der angegebene Landkreis "%s" existiert nicht' % (landkreis)) + return + + bare = str(msg.from_.bare()) + if self._sum.is_subscribed_to(bare, landkreis): + self.send_message(msg.from_, + 'Du hast den "%s" bereits abonniert' % (landkreis)) + return + + self._sum.add_subscription_for(bare, landkreis) + for warning in self._warnings0: + if landkreis in warning.landkreise: + body = format_warning(warning) + self.send_message(msg.from_, body) + + async def _unsubscribe(self, cmd, msg): + if len(cmd) < 2: + self.send_message(msg.from_, 'Du hast keinen Landkreis angegeben') + return + + landkreis = ' '.join(cmd[1:]) + bare = str(msg.from_.bare()) + if not self._sum.is_subscribed_to(bare, landkreis): + self.send_message(msg.from_, 'Du hast "%s" nicht abonniert' % (landkreis)) + return + + self._sum.remove_subscription_for(bare, landkreis) + self.send_message(msg.from_, 'Du erhälst nun keine Nachrichten zu "%s" mehr' % (landkreis)) + + async def _help(self, cmd, msg): + body = '''Verfügbare Befehle: + +subscribe - Abonniere einen Landkreis +unsubscribe - Entferne das Abonnement zu einem Landkreis +help - Gebe diese Hilfe aus''' + self.send_message(msg.from_, body) + + async def _any(self, cmd, msg): + if not cmd: + self.send_message(msg.from_, + 'Ich bin die Jabber Anwendung für Notfallinformations- und -Nachrichten-Empfang') + else: + self.send_message(msg.from_, + 'Unbekannter Befehl "%s". "janine hilfe" gibt alle bekannten Befehle aus' % (cmd[0])) + + +def get_instance(base, **kwargs): + return JanineModule.get_instance(base, **kwargs) diff --git a/janine/sources.py b/mira/modules/janine/sources.py similarity index 86% rename from janine/sources.py rename to mira/modules/janine/sources.py index 8a5b513..85c59c4 100644 --- a/janine/sources.py +++ b/mira/modules/janine/sources.py @@ -54,13 +54,3 @@ class MiscDataSources: These are the valid names to retrieve warnings for ''' return 'https://warnung.bund.de/assets/json/converted_gemeinden.json' - -def sources_from_config(config): - sources = [] - for module in ('IHP', 'DWD', 'BIWAPP', 'MOWAS'): - option = config['General'].get(module, 'n') - - if option == 'y': - sources.append(WarningSources.source_by_name(module)) - - return sources diff --git a/mira/modules/janine/warnings.py b/mira/modules/janine/warnings.py new file mode 100644 index 0000000..fd416df --- /dev/null +++ b/mira/modules/janine/warnings.py @@ -0,0 +1,82 @@ +''' +Copyright (C) 2021 Alexander "PapaTutuWawa" + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' +from collections import namedtuple + +from mira.modules.janine.helpers import find_one + +Warning_ = namedtuple('Warning_', ['id', + 'sent', + 'effective_from', + 'expires', + 'urgency', + 'sender', + 'headline', + 'description', + 'instruction', + 'landkreise']) + + +def stub_warning(id_): + ''' + Returns a stubbed warning for loading warnings from disk. + The only real attribute is the @id_ . + ''' + return Warning_(id=id_, + sent='', + effective_from='', + expires='', + urgency='', + sender='', + headline='', + description='', + instruction='', + landkreise=[]) + +def to_warning(data): + ''' + Returns a Warning given the raw data + ''' + info = find_one(lambda x: 'headline' in x.keys(), data['info']) + return Warning_(id=data['identifier'], + sent=data['sent'], + # Not all items have to have those + effective_from=info.get('effective', 'N/A'), + # Not all items have to have those + expires=info.get('expires', 'N/A'), + urgency=info['urgency'], + # Not all items have to have those + sender=info.get('senderName', 'N/A'), + headline=info['headline'], + description=info['description'], + instruction=info.get('instruction', ''), + landkreise=get_landkreise(data)) + +def get_landkreise(data): + ''' + Returns the list of Landkreise relevant to the warning in @data + ''' + info = find_one(lambda e: 'area' in e.keys(), data['info']) + geocode = find_one(lambda e: 'geocode' in e.keys(), info['area']) + + # Note: Some items may have multiple Landkreise + return [e['valueName'] for e in geocode['geocode']] + +def parse_data(data): + ''' + Reads the remote response, parses it and returns a list of warnings. + ''' + return [to_warning(el) for el in data] diff --git a/setup.py b/setup.py index d373ee4..69e2e3b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name = 'janine', - version = '0.4.0', + version = '1.0.0', description = 'An XMPP bot relaying data from the IHP, DWD, MOWAS and BIWAP', url = 'https://git.polynom.me/PapaTutuWawa/janine', author = 'Alexander "PapaTutuWawa"', @@ -13,10 +13,5 @@ setup( 'aioxmpp>=0.11.0', 'requests>=2.23.0' ], - zip_safe=True, - entry_points={ - 'console_scripts': [ - 'janine = janine.janine:main' - ] - } + zip_safe=True )