class SMS900(): """ The main class to use """ def __init__(self, configuration_path): """ The init method for the main class. Should probably add an example or two here. """ self.configuration_path = configuration_path self.events = queue.Queue() self.config = None self.dbconn = None self.irc_thread = None self.pb = None def run(self): """ Starts the main loop""" self._load_configuration() self._init_database() self.pb = PhoneBook(self.dbconn) self.indexer = Indexer() logging.info("Starting IRCThread thread") self.irc_thread = IRCThread(self, self.config['server'], self.config['server_port'], self.config['nickname'], self.config['channel']) self.irc_thread.start() logging.info("Starting webserver") http_thread = HTTPThread(self, ('0.0.0.0', 8090)) http_thread.start() logging.info("Starting main loop") self._main_loop() def _load_configuration(self): with open(self.configuration_path, 'r') as file: self.config = json.load(file) # FIXME: Check that we got everything we'll be needing def _init_database(self): self.dbconn = sqlite3.connect('sms900.db', isolation_level=None) conn = self.dbconn.cursor() try: conn.execute( "create table phonebook (" " id integer primary key," " nickname text UNIQUE," " number text UNIQUE" ")" ) conn.execute( "create table phonebook_email (" " email text primary key collate nocase," " nickname text" ")" ) except sqlite3.Error as err: logging.info("Failed to create table(s): %s", err) def queue_event(self, event_type, data): """ queues event, stupid doc string i know """ event = {'event_type': event_type} event.update(data) self.events.put(event) def _main_loop(self): while True: event = self.events.get() self._handle_event(event) def _handle_event(self, event): try: logging.info('EVENT: %s', event) if event['event_type'] == 'SEND_SMS': sender_hm = event['hostmask'] number = self._get_num_from_nick_or_num(event['number']) nickname = self._get_nickname_from_hostmask(sender_hm) # FIXME: Check the sender msg = "<%s> %s" % (nickname, event['msg']) self._send_sms(number, msg) elif event['event_type'] == 'ADD_PB_ENTRY': nickname = event['nickname'] if event['number']: number = self._get_canonicalized_number(event['number']) self.pb.add_number(nickname, number) self._send_privmsg(self.config['channel'], 'Added %s with number %s' % (nickname, number)) elif event['email']: email = event['email'] self.pb.add_email(nickname, email) self._send_privmsg(self.config['channel'], 'Added email %s for %s' % (email, nickname)) elif event['event_type'] == 'DEL_PB_ENTRY': if event['nickname']: nickname = event['nickname'] oldnumber = self.pb.get_number(nickname) self.pb.del_entry(nickname) self._send_privmsg(self.config['channel'], 'Removed contact %s (number: %s)' % (nickname, oldnumber)) elif event['email']: email = event['email'] nickname = self.pb.get_nickname_from_email(email) self.pb.del_email(email) self._send_privmsg(self.config['channel'], 'Removed %s for contact %s' % (email, nickname)) elif event['event_type'] == 'LOOKUP_CARRIER': number = event['number'] number = self._get_canonicalized_number(number) self._lookup_carrier(number) elif event['event_type'] == 'REINDEX_ALL': self._reindex_all() elif event['event_type'] == 'SMS_RECEIVED': number = event['number'] sms_msg = event['msg'] try: sender = self.pb.get_nickname(number) except SMS900InvalidAddressbookEntry: sender = number msg = '<%s> %s' % (sender, sms_msg) self._send_privmsg(self.config['channel'], msg) elif event['event_type'] == 'GITHUB_WEBHOOK': self._handle_github_event(event['data']) elif event['event_type'] == 'MAILGUN_INCOMING': self._handle_incoming_mms(event['data']) except (SMS900InvalidNumberFormatException, SMS900InvalidAddressbookEntry) as err: self._send_privmsg(self.config['channel'], "Error: %s" % err) except Exception as err: self._send_privmsg(self.config['channel'], "Unknown error: %s" % err) traceback.print_exc() def _send_sms(self, number, message): logging.info('Sending sms ( %s -> %s )', message, number) try: client = TwilioRestClient(self.config['twilio_account_sid'], self.config['twilio_auth_token']) message_data = client.messages.create(to=number, from_=self.config['twilio_number'], body=message,) self._send_privmsg(self.config['channel'], "Sent %s sms to number %s" % (message_data.num_segments, number)) except twilio.TwilioRestException as err: self._send_privmsg(self.config['channel'], "Failed to send sms: %s" % err) def _lookup_carrier(self, number): logging.info('Looking up number %s', number) try: client = TwilioLookupsClient(self.config['twilio_account_sid'], self.config['twilio_auth_token']) number_data = client.phone_numbers.get(number, include_carrier_info=True) self._send_privmsg(self.config['channel'], '%s is %s, carrier: %s' % (number, number_data.carrier['type'], number_data.carrier['name'])) except twilio.TwilioRestException as err: self._send_privmsg(self.config['channel'], "Failed to lookup number: %s" % err) def _send_privmsg(self, target, msg): self.irc_thread.send_privmsg(target, msg) def _get_canonicalized_number(self, number): match = re.match(r'^\+[0-9]+$', number) if match: logging.info('number %s already canonicalized, returning as is', number) return number match = re.match(r'^0(7[0-9]{8})$', number) if match: new_number = '+46%s' % match.group(1) logging.info('number %s was canonicalized and returned as %s', number, new_number) return new_number raise SMS900InvalidNumberFormatException("%s is not a valid number", number) def _get_num_from_nick_or_num(self, number_or_name): try: number_or_name = self._get_canonicalized_number(number_or_name) except SMS900InvalidNumberFormatException: try: number_or_name = self.pb.get_number(number_or_name) except SMS900InvalidAddressbookEntry as err2: raise SMS900InvalidNumberFormatException( "%s is not a valid number or existing nickname: %s" % (number_or_name, err2)) return number_or_name def _get_nickname_from_hostmask(self, hostmask): nick = re.match(r'^([^\!]+)', hostmask) if not nick: # FIXME return hostmask else: return nick.group(1) def _handle_github_event(self, data): try: repository_name = data['repository']['full_name'] for commit in data['commits']: self._send_privmsg( self.config['channel'], "[%s] %s (%s)" % ( repository_name, commit['message'], commit['author']['username'] ) ) except KeyError as err: logging.exception("Failed to parse data from github webhook, reason: %s", err) def _handle_incoming_mms(self, data): rel_path = str(uuid.uuid4()) save_path = path.join( self.config['mms_save_path'], rel_path ) mkdir(save_path) [sender, files] = self._parse_mms_data(data, save_path) sender = self._map_mms_sender_to_nickname(sender) base_url = "%s/%s" % ( self.config['external_mms_url'], rel_path ) mms_summary, summary_contains_all = self._get_mms_summary(base_url, files) if mms_summary: self._send_privmsg( self.config['channel'], "[MMS] <%s> %s" % (sender, mms_summary) ) if not summary_contains_all: self._send_privmsg( self.config['channel'], "[MMS] <%s> Received %d file(s): %s" % (sender, len(files), base_url) ) self.indexer.generate_local_index(save_path) self.indexer.generate_global_index(self.config['mms_save_path']) def _reindex_all(self): self.indexer.reindex_all(self.config['mms_save_path']) def _map_mms_sender_to_nickname(self, sender): m = re.match('^([^<]*<)?([^<]+@[^>]+)>?', sender) if not m: return sender email = m.group(2) try: return self.pb.get_nickname_from_email(email) except SMS900InvalidAddressbookEntry: logging.exception("No nickname found for email %s", email) m = re.match('^([0-9]+)\@', email) if m: try: number = self._get_canonicalized_number("+" + m.group(1)) return self.pb.get_nickname(number) except SMS900InvalidAddressbookEntry: logging.exception("No number found for %s" , m.group(1)) except SMS900InvalidNumberFormatException: logging.exception("Weirdly formatted number: %s", m.group(1)) return email def _parse_mms_data(self, data, save_path): payload = data['payload'] files = [] i = 0 sender = None if data['type'] == 'form-data': for part in payload.parts: disposition = part.headers[b'Content-Disposition'].decode('utf-8', 'ignore') if 'name="from"' in disposition: sender = part.content.decode('utf-8', 'ignore') continue elif 'name="body-plain"' in disposition: contents = part.content.decode('utf-8', 'ignore') if not len(contents.strip()): continue filename = path.join(save_path, '%d-body.txt' % i) i += 1 with open(filename, 'w') as f: f.write(contents) files.append(filename) continue m = re.search('filename="([^"]+)"', disposition, re.IGNORECASE) if m: filename = path.join(save_path, '%d-%s' % (i, m.group(1))) i += 1 with open(filename, 'wb') as f: f.write(part.content) files.append(filename) continue if b'Content-Type' in part.headers: # Probably something we need to handle better, but # let's just dump it in a file for now. filename = path.join(save_path, '%d-unknown' % i) i += 1 with open(filename, 'wb') as f: f.write(part.content) files.append(filename) continue logging.info("Ignoring: %s" % disposition) elif data['type'] == 'urlencoded': if 'sender' in payload: for _sender in payload['sender']: sender = _sender break if 'body-plain' in payload: for body in payload['body-plain']: filename = path.join(save_path, '%d-body.txt' % i) i += 1 with open(filename, 'w') as f: f.write(body) files.append(filename) return [sender, files] def _get_mms_summary(self, base_url, files): try: text = None img_url = None # Find the first text and the first image file, if any for full_path in files: match = re.search(r'\.([^.]+)$', full_path) if match: extension = match.group(1).lower() if not img_url: if extension in ['jpg', 'jpeg', 'png']: filename = path.basename(full_path) img_url = "%s/%s" % (base_url, filename) if not text: if extension in ['txt']: with open(full_path, 'r', encoding='utf-8', errors='ignore') as file: text = file.read() # Only show one line text_lines = text.splitlines() if len(text_lines): text = text_lines[0] if len(text_lines) > 1: text += " [%d lines]" % len(text_lines) if text or img_url: parts = [p for p in [text, img_url] if p] message = ", ".join(parts) summary_contains_all = (len(parts) == len(files)) return message, summary_contains_all except Exception: traceback.print_exc() return None, False
class SMS900(): """ The main class to use """ def __init__(self, configuration_path): """ The init method for the main class. Should probably add an example or two here. """ self.configuration_path = configuration_path self.events = queue.Queue() self.config = None self.dbconn = None self.irc_thread = None self.pb = None def run(self): """ Starts the main loop""" self._load_configuration() self._init_database() self.pb = PhoneBook(self.dbconn) self.indexer = Indexer() logging.info("Starting IRCThread thread") self.irc_thread = IRCThread(self, self.config['server'], self.config['server_port'], self.config['nickname'], self.config['channel']) self.irc_thread.start() logging.info("Starting webserver") http_thread = HTTPThread(self, ('0.0.0.0', 8090)) http_thread.start() logging.info("Starting main loop") self._main_loop() def _load_configuration(self): with open(self.configuration_path, 'r') as file: self.config = json.load(file) # FIXME: Check that we got everything we'll be needing def _init_database(self): self.dbconn = sqlite3.connect('sms900.db', isolation_level=None) conn = self.dbconn.cursor() try: conn.execute("create table phonebook (" " id integer primary key," " nickname text UNIQUE," " number text UNIQUE" ")") conn.execute("create table phonebook_email (" " email text primary key collate nocase," " nickname text" ")") except sqlite3.Error as err: logging.info("Failed to create table(s): %s", err) def queue_event(self, event_type, data): """ queues event, stupid doc string i know """ event = {'event_type': event_type} event.update(data) self.events.put(event) def _main_loop(self): while True: event = self.events.get() self._handle_event(event) def _handle_event(self, event): try: logging.info('EVENT: %s', event) if event['event_type'] == 'SEND_SMS': sender_hm = event['hostmask'] number = self._get_num_from_nick_or_num(event['number']) nickname = self._get_nickname_from_hostmask(sender_hm) # FIXME: Check the sender msg = "<%s> %s" % (nickname, event['msg']) self._send_sms(number, msg) elif event['event_type'] == 'ADD_PB_ENTRY': nickname = event['nickname'] if event['number']: number = self._get_canonicalized_number(event['number']) self.pb.add_number(nickname, number) self._send_privmsg( self.config['channel'], 'Added %s with number %s' % (nickname, number)) elif event['email']: email = event['email'] self.pb.add_email(nickname, email) self._send_privmsg( self.config['channel'], 'Added email %s for %s' % (email, nickname)) elif event['event_type'] == 'DEL_PB_ENTRY': if event['nickname']: nickname = event['nickname'] oldnumber = self.pb.get_number(nickname) self.pb.del_entry(nickname) self._send_privmsg( self.config['channel'], 'Removed contact %s (number: %s)' % (nickname, oldnumber)) elif event['email']: email = event['email'] nickname = self.pb.get_nickname_from_email(email) self.pb.del_email(email) self._send_privmsg( self.config['channel'], 'Removed %s for contact %s' % (email, nickname)) elif event['event_type'] == 'LOOKUP_CARRIER': number = event['number'] number = self._get_canonicalized_number(number) self._lookup_carrier(number) elif event['event_type'] == 'REINDEX_ALL': self._reindex_all() elif event['event_type'] == 'SMS_RECEIVED': number = event['number'] sms_msg = event['msg'] try: sender = self.pb.get_nickname(number) except SMS900InvalidAddressbookEntry: sender = number msg = '<%s> %s' % (sender, sms_msg) self._send_privmsg(self.config['channel'], msg) elif event['event_type'] == 'GITHUB_WEBHOOK': self._handle_github_event(event['data']) elif event['event_type'] == 'MAILGUN_INCOMING': self._handle_incoming_mms(event['data']) except (SMS900InvalidNumberFormatException, SMS900InvalidAddressbookEntry) as err: self._send_privmsg(self.config['channel'], "Error: %s" % err) except Exception as err: self._send_privmsg(self.config['channel'], "Unknown error: %s" % err) traceback.print_exc() def _send_sms(self, number, message): logging.info('Sending sms ( %s -> %s )', message, number) try: client = TwilioRestClient(self.config['twilio_account_sid'], self.config['twilio_auth_token']) message_data = client.messages.create( to=number, from_=self.config['twilio_number'], body=message, ) self._send_privmsg( self.config['channel'], "Sent %s sms to number %s" % (message_data.num_segments, number)) except twilio.TwilioRestException as err: self._send_privmsg(self.config['channel'], "Failed to send sms: %s" % err) def _lookup_carrier(self, number): logging.info('Looking up number %s', number) try: client = TwilioLookupsClient(self.config['twilio_account_sid'], self.config['twilio_auth_token']) number_data = client.phone_numbers.get(number, include_carrier_info=True) self._send_privmsg( self.config['channel'], '%s is %s, carrier: %s' % (number, number_data.carrier['type'], number_data.carrier['name'])) except twilio.TwilioRestException as err: self._send_privmsg(self.config['channel'], "Failed to lookup number: %s" % err) def _send_privmsg(self, target, msg): self.irc_thread.send_privmsg(target, msg) def _get_canonicalized_number(self, number): match = re.match(r'^\+[0-9]+$', number) if match: logging.info('number %s already canonicalized, returning as is', number) return number match = re.match(r'^0(7[0-9]{8})$', number) if match: new_number = '+46%s' % match.group(1) logging.info('number %s was canonicalized and returned as %s', number, new_number) return new_number raise SMS900InvalidNumberFormatException("%s is not a valid number", number) def _get_num_from_nick_or_num(self, number_or_name): try: number_or_name = self._get_canonicalized_number(number_or_name) except SMS900InvalidNumberFormatException: try: number_or_name = self.pb.get_number(number_or_name) except SMS900InvalidAddressbookEntry as err2: raise SMS900InvalidNumberFormatException( "%s is not a valid number or existing nickname: %s" % (number_or_name, err2)) return number_or_name def _get_nickname_from_hostmask(self, hostmask): nick = re.match(r'^([^\!]+)', hostmask) if not nick: # FIXME return hostmask else: return nick.group(1) def _handle_github_event(self, data): try: repository_name = data['repository']['full_name'] for commit in data['commits']: self._send_privmsg( self.config['channel'], "[%s] %s (%s)" % (repository_name, commit['message'], commit['author']['username'])) except KeyError as err: logging.exception( "Failed to parse data from github webhook, reason: %s", err) def _handle_incoming_mms(self, data): rel_path = str(uuid.uuid4()) save_path = path.join(self.config['mms_save_path'], rel_path) mkdir(save_path) [sender, files] = self._parse_mms_data(data, save_path) sender = self._map_mms_sender_to_nickname(sender) base_url = "%s/%s" % (self.config['external_mms_url'], rel_path) mms_summary, summary_contains_all = self._get_mms_summary( base_url, files) if mms_summary: self._send_privmsg(self.config['channel'], "[MMS] <%s> %s" % (sender, mms_summary)) if not summary_contains_all: self._send_privmsg( self.config['channel'], "[MMS] <%s> Received %d file(s): %s" % (sender, len(files), base_url)) self.indexer.generate_local_index(save_path) self.indexer.generate_global_index(self.config['mms_save_path']) def _reindex_all(self): self.indexer.reindex_all(self.config['mms_save_path']) def _map_mms_sender_to_nickname(self, sender): m = re.match('^([^<]*<)?([^<]+@[^>]+)>?', sender) if not m: return sender email = m.group(2) try: return self.pb.get_nickname_from_email(email) except SMS900InvalidAddressbookEntry: logging.exception("No nickname found for email %s", email) m = re.match('^([0-9]+)\@', email) if m: try: number = self._get_canonicalized_number("+" + m.group(1)) return self.pb.get_nickname(number) except SMS900InvalidAddressbookEntry: logging.exception("No number found for %s", m.group(1)) except SMS900InvalidNumberFormatException: logging.exception("Weirdly formatted number: %s", m.group(1)) return email def _parse_mms_data(self, data, save_path): payload = data['payload'] if data['type'] == 'form-data': return self._parse_form_mms_data(payload, save_path) elif data['type'] == 'urlencoded': return self._parse_urlencoded_mms_data(payload, save_path) else: return [None, []] def _parse_form_mms_data(self, payload, save_path): files = [] sender = None i = 0 for part in payload.parts: disposition = part.headers[b'Content-Disposition'].decode( 'utf-8', 'ignore') m = re.search('name="([^"]+)"', disposition, re.IGNORECASE) if m: disposition_name = m.group(1) if disposition_name == 'from': sender = part.content.decode('utf-8', 'ignore') continue elif disposition_name in ['body-plain', 'subject']: contents = part.content.decode('utf-8', 'ignore') if not len(contents.strip()): continue filename = path.join(save_path, '%d-%s.txt' % (i, disposition_name)) i += 1 with open(filename, 'w') as f: f.write(contents) files.append(filename) continue m = re.search('filename="([^"]+)"', disposition, re.IGNORECASE) if m: filename = path.join(save_path, '%d-%s' % (i, m.group(1))) i += 1 with open(filename, 'wb') as f: f.write(part.content) files.append(filename) continue if b'Content-Type' in part.headers: # Probably something we need to handle better, but # let's just dump it in a file for now. filename = path.join(save_path, '%d-unknown' % i) i += 1 with open(filename, 'wb') as f: f.write(part.content) files.append(filename) continue logging.info("Ignoring: %s" % disposition) return [sender, files] def _parse_urlencoded_mms_data(self, payload, save_path): files = [] sender = None i = 0 if 'sender' in payload: for _sender in payload['sender']: sender = _sender break for text_type in ['body-plain', 'subject']: if text_type in payload: for body in payload[text_type]: if not len(body.strip()): continue filename = path.join(save_path, '%d-%s.txt' % (i, text_type)) i += 1 with open(filename, 'w') as f: f.write(body) files.append(filename) return [sender, files] def _get_mms_summary(self, base_url, files): try: text = None text_was_cut = False img_url = None # Find the first text and the first image file, if any for full_path in files: match = re.search(r'\.([^.]+)$', full_path) if match: extension = match.group(1).lower() if not img_url: if extension in ['jpg', 'jpeg', 'png']: filename = path.basename(full_path) img_url = "%s/%s" % (base_url, filename) if not text: if extension in ['txt']: with open(full_path, 'r', encoding='utf-8', errors='ignore') as file: text = file.read() # Only show one line text_lines = text.splitlines() if len(text_lines): text = text_lines[0] if len(text_lines) > 1: text_was_cut = True text += " [%d lines]" % len(text_lines) if text or img_url: parts = [p for p in [text, img_url] if p] message = ", ".join(parts) summary_contains_all = (len(parts) == len(files)) and not text_was_cut return message, summary_contains_all except Exception: traceback.print_exc() return None, False