JANINE/janine/janine.py

381 lines
14 KiB
Python
Raw Normal View History

2020-09-17 16:19:57 +00:00
'''
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 <https://www.gnu.org/licenses/>.
'''
import os
2020-08-22 13:09:59 +00:00
import sys
import json
import configparser
import logging
import asyncio
from collections import namedtuple
import aioxmpp
from aioxmpp.structs import PresenceShow
import requests
2020-09-17 16:19:57 +00:00
from janine.utils import find_one, make_msg, format_warning
from janine.sources import sources_from_config, MiscDataSources
2020-08-22 13:09:59 +00:00
log = logging.getLogger('janine')
2020-09-17 15:47:08 +00:00
log.setLevel(logging.INFO)
log.addHandler(logging.StreamHandler())
2020-08-22 13:09:59 +00:00
2020-09-17 16:19:57 +00:00
Warning_ = namedtuple('Warning_', ['id',
'sent',
'effective_from',
'expires',
'urgency',
'sender',
'headline',
'description',
'instruction',
'landkreise'])
2020-08-22 13:09:59 +00:00
2020-09-17 15:47:08 +00:00
def stub_warning(id_):
'''
Returns a stubbed warning for loading warnings from disk.
The only real attribute is the @id_ .
'''
2020-09-17 16:19:57 +00:00
return Warning_(id=id_,
sent='',
effective_from='',
expires='',
urgency='',
sender='',
headline='',
description='',
instruction='',
landkreise=[])
2020-09-17 15:47:08 +00:00
2020-08-22 13:09:59 +00:00
def to_warning(data):
'''
Returns a Warning given the raw data
'''
info = find_one(lambda x: 'headline' in x.keys(), data['info'])
2020-09-17 16:19:57 +00:00
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', 'N/A'),
landkreise=get_landkreise(data))
2020-08-22 13:09:59 +00:00
2020-09-17 13:32:12 +00:00
def get_landkreise(data):
2020-08-22 13:09:59 +00:00
'''
2020-09-17 13:32:12 +00:00
Returns the list of Landkreise relevant to the warning in @data
2020-08-22 13:09:59 +00:00
'''
2020-09-17 13:32:12 +00:00
info = find_one(lambda e: 'area' in e.keys(), data['info'])
2020-08-22 13:09:59 +00:00
geocode = find_one(lambda e: 'geocode' in e.keys(), info['area'])
# Note: Some items may have multiple Landkreise
2020-09-17 16:19:57 +00:00
return list(map(lambda e: e['valueName'], geocode['geocode']))
2020-08-22 13:09:59 +00:00
2020-09-17 13:32:12 +00:00
def parse_data(text):
2020-08-22 13:09:59 +00:00
'''
Reads the remote response, parses it and returns a list of warnings.
'''
data = json.loads(text)
2020-09-17 13:32:12 +00:00
return [to_warning(el) for el in data]
2020-08-22 13:09:59 +00:00
class WarningBot:
2020-09-17 16:19:57 +00:00
'''
This class represents the actual bot. The only things
to be done is call connect() after creating an instance.
'''
2020-08-22 13:09:59 +00:00
def __init__(self):
self._warnings0 = []
self._warnings1 = []
self._client = None
2020-09-17 13:32:12 +00:00
self._warn_clients = {}
2020-08-22 13:09:59 +00:00
self._refresh_timeout = 630 # 15min
# Configuration stuff
self._data_dir = ''
self._client_store = ''
self._warning_store = ''
2020-08-22 13:09:59 +00:00
self._load_config()
2020-08-22 13:09:59 +00:00
async def connect(self):
'''
Starts the "event loop" of the bot
'''
self._client = aioxmpp.PresenceManagedClient(
2020-09-17 16:19:57 +00:00
self._jid,
aioxmpp.make_security_layer(self._password))
2020-08-22 13:09:59 +00:00
2020-09-17 16:19:57 +00:00
async with self._client.connected():
2020-09-17 15:47:08 +00:00
log.info('Client connected to server')
2020-08-22 13:09:59 +00:00
# In case you want a nice avatar
if self._avatar:
log.info('Setting avatar')
2020-09-17 16:19:57 +00:00
with open(self._avatar, 'rb') as avatar_file:
image_data = avatar_file.read()
2020-08-22 13:09:59 +00:00
avatar_set = aioxmpp.avatar.AvatarSet()
avatar_set.add_avatar_image('image/png', image_bytes=image_data)
2020-09-17 15:47:08 +00:00
await (self._client.summon(aioxmpp.avatar.AvatarService)
2020-09-17 16:19:57 +00:00
.publish_avatar_set(avatar_set))
2020-09-17 15:47:08 +00:00
2020-08-22 13:09:59 +00:00
# Set some presence information
self._client.set_presence(
2020-09-17 16:19:57 +00:00
aioxmpp.PresenceState(available=True,
show=PresenceShow.CHAT),
self._status)
2020-09-17 15:47:08 +00:00
# Enable Carbons
await self._client.summon(aioxmpp.CarbonsClient).enable()
log.info('Message carbons enabled')
2020-09-17 13:32:12 +00:00
# Register the message handler
self._client.stream.register_message_callback(
2020-09-17 16:19:57 +00:00
aioxmpp.MessageType.CHAT,
None,
self._handle_message)
2020-09-17 15:47:08 +00:00
log.info('Message handler registered')
2020-09-17 13:32:12 +00:00
2020-08-22 13:09:59 +00:00
# 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())
2020-09-17 16:19:57 +00:00
log.info('Periodic ticker started')
2020-08-22 13:09:59 +00:00
await periodic
2020-09-17 13:32:12 +00:00
def __is_message_valid(self, msg):
'''
Returns True on messages we want to handle. False otherwise.
'''
2020-09-17 13:32:12 +00:00
if msg.type_ != aioxmpp.MessageType.CHAT:
return False
2020-09-17 16:19:57 +00:00
2020-09-17 13:32:12 +00:00
if not msg.body:
return False
2020-09-17 16:19:57 +00:00
if msg.from_.domain != self._jid.domain and self._same_domain:
2020-09-17 13:32:12 +00:00
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
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:
2020-09-17 16:19:57 +00:00
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
2020-09-17 16:19:57 +00:00
body='Du hast keinen Landkreis angegeben'))
2020-09-17 13:32:12 +00:00
return
# Check if the entered Landkreis is valid
landkreis = ' '.join(cmd_parts[1:])
if not landkreis in self._channels:
2020-09-17 16:19:57 +00:00
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
2020-09-17 16:19:57 +00:00
body='Der angegebene Landkreis ist ungültig'))
2020-09-17 13:32:12 +00:00
return
2020-09-17 16:19:57 +00:00
if landkreis not in self._warn_clients.keys():
2020-09-17 13:32:12 +00:00
self._warn_clients[landkreis] = []
self._warn_clients[landkreis].append(str(msg.from_.bare()))
2020-09-17 13:32:12 +00:00
2020-09-17 16:19:57 +00:00
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
body=f'Du erhälst nun Nachrichten zu {landkreis} von mir'))
2020-09-17 16:19:57 +00:00
with open(self._client_store, 'w') as clients_file:
clients_file.write(json.dumps(self._warn_clients))
2020-09-17 13:32:12 +00:00
# Send all known warnings for the landkreis to the user
for warning in self._warnings0:
if landkreis in warning.landkreise:
2020-09-17 16:19:57 +00:00
body = format_warning(warning)
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
body=body))
elif cmd == 'unsubscribe':
# Do we have a landkreis?
if len(cmd_parts) < 2:
2020-09-17 16:19:57 +00:00
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
2020-09-17 16:19:57 +00:00
body='Du hast keinen Landkreis angegeben'))
2020-09-17 13:32:12 +00:00
return
2020-09-17 16:19:57 +00:00
2020-09-17 13:32:12 +00:00
landkreis = ' '.join(cmd_parts[1:])
2020-09-17 16:19:57 +00:00
if landkreis not in self._warn_clients:
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
2020-09-17 16:19:57 +00:00
body=f'Du hast {landkreis} nicht abonniert'))
2020-09-17 13:32:12 +00:00
return
if str(msg.from_.bare()) in self._warn_clients[landkreis]:
filter_ = lambda x: x != str(msg.from_.bare())
2020-09-17 13:32:12 +00:00
self._warn_clients[landkreis] = list(filter(filter_,
self._warn_clients[landkreis]))
2020-09-17 16:19:57 +00:00
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
2020-09-17 16:19:57 +00:00
body=f'Du erhälst keine Nachrichten zu {landkreis} mehr von mir'))
if len(self._warn_clients[landkreis]) == 0:
del self._warn_clients[landkreis]
2020-09-17 13:32:12 +00:00
else:
2020-09-17 16:19:57 +00:00
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
body=f'Du hast {landkreis} nicht abonniert'))
elif cmd == 'help':
2020-09-17 16:19:57 +00:00
body = '''Verfügbare Befehle:
subscribe <Landkreis> - Abonniere einen Landkreis
unsubscribe <Landkreis> - Entferne das Abonnement zu einem Landkreis
help - Gebe diese Hilfe aus'''
self._client.enqueue(make_msg(
2020-09-17 13:32:12 +00:00
to=msg.from_,
body=body))
else:
2020-09-17 16:19:57 +00:00
self._client.enqueue(make_msg(
to=msg.from_,
body='Diesen Befehl kenne ich nicht... Mit "help" kannst du alle Befehle sehen, die ich kenne.'))
2020-09-17 13:32:12 +00:00
2020-08-22 13:09:59 +00:00
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:
2020-09-17 15:47:08 +00:00
log.debug('Refreshing warning list')
2020-08-22 13:09:59 +00:00
await self._fetch_warnings()
2020-09-17 16:19:57 +00:00
2020-09-17 15:47:08 +00:00
# Flush the warnings to disk
ids = list(map(lambda x: x.id, self._warnings0))
2020-09-17 16:19:57 +00:00
with open(self._warnings_file, 'w') as warnings_file:
warnings_file.write(json.dumps(ids))
2020-09-17 15:47:08 +00:00
2020-08-22 13:09:59 +00:00
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:
req = requests.get(source)
2020-09-17 13:32:12 +00:00
self._warnings0 = parse_data(req.text)
2020-09-17 16:19:57 +00:00
2020-08-22 13:09:59 +00:00
# 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
2020-09-17 16:19:57 +00:00
2020-09-17 13:32:12 +00:00
if len(set(warning.landkreise).intersection(self._warn_clients.keys())):
2020-09-17 16:19:57 +00:00
body = format_warning(warning)
2020-09-17 13:32:12 +00:00
for landkreis in warning.landkreise:
for to in self._warn_clients.get(landkreis, []):
msg = aioxmpp.stanza.Message(
to=aioxmpp.JID.fromstr(to),
type_=aioxmpp.MessageType.CHAT)
msg.body[None] = body
await self._client.send(msg)
2020-08-22 13:09:59 +00:00
def _load_config(self):
# Load config
config_path = sys.argv[1] if len(sys.argv) == 2 else '/etc/janine/janine.conf'
config = configparser.ConfigParser()
config.read(config_path)
2020-09-17 16:19:57 +00:00
2020-08-22 13:09:59 +00:00
# Configure sources
self._sources = sources_from_config(config)
self._data_dir = config['General'].get('DataDir', '/etc/janine')
2020-08-22 13:09:59 +00:00
self._recipients = config['General']['Recipients'].split(',')
self._refresh_timeout = int(config['General']['Timeout'])
self._same_domain = config['General'].get('SameDomain', 'True') == 'True'
2020-09-17 16:19:57 +00:00
# Persistent data
# Subscribed clients
self._client_store = os.path.join(self._data_dir, 'clients.json')
if os.path.exists(self._client_store):
2020-09-17 16:19:57 +00:00
with open(self._client_store, 'r') as clients_file:
self._warn_clients = json.loads(clients_file.read())
2020-09-17 15:47:08 +00:00
log.info('Clients read from disk')
2020-09-17 16:19:57 +00:00
2020-09-17 15:47:08 +00:00
self._warnings_file = os.path.join(self._data_dir, 'warnings.json')
if os.path.exists(self._warnings_file):
2020-09-17 16:19:57 +00:00
with open(self._warnings_file, 'r') as warnings_file:
self._warnings0 = list(map(stub_warning,
json.loads(warnings_file.read())))
2020-09-17 15:47:08 +00:00
log.info('Warnings read from disk')
# Landkreise
self._channels = []
channels = {}
2020-09-17 16:19:57 +00:00
self._channel_file = os.path.join(self._data_dir, 'channels.json')
if not os.path.exists(self._channel_file):
2020-09-17 15:47:08 +00:00
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', ''),
2020-09-17 16:19:57 +00:00
channels.keys()))
try:
2020-09-17 16:19:57 +00:00
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:
2020-09-17 16:19:57 +00:00
with open(self._channel_file, 'r') as channel_file:
self._channels = json.loads(channel_file.read())
2020-09-17 15:47:08 +00:00
log.info('Search channels read from disk')
2020-08-22 13:09:59 +00:00
# Bot Config
self._jid = aioxmpp.JID.fromstr(config['Bot']['JID'])
self._password = config['Bot']['Password']
2020-08-22 13:11:55 +00:00
self._avatar = config['Bot'].get('Avatar', None)
self._status = config['Bot'].get('Status', 'Warnt dich vor Katastrophen')
2020-08-22 13:09:59 +00:00
def main():
2020-09-17 16:19:57 +00:00
'''
Main function.
'''
2020-08-22 13:09:59 +00:00
bot = WarningBot()
loop = asyncio.get_event_loop()
loop.run_until_complete(bot.connect())
loop.close()
if __name__ == '__main__':
main()