''' 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 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 = list(map(lambda x: x.id, 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 = map(lambda x: x.id, 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 = list(map(lambda key: channels[key].get('NAME', ''), channels.keys())) 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()