├── hangouts_to_sms.py ├── conversation.py ├── attachment.py ├── message.py ├── participant.py ├── LICENSE ├── .gitignore ├── README.md ├── hangouts_parser.py └── titanium_backup_formatter.py /hangouts_to_sms.py: -------------------------------------------------------------------------------- 1 | from hangouts_parser import HangoutsParser 2 | from titanium_backup_formatter import TitaniumBackupFormatter 3 | 4 | 5 | # Configuration constants 6 | HANGOUTS_JSON_FILE = 'Hangouts.json' 7 | OUTPUT_FILE = "messages.xml" 8 | YOUR_PHONE_NUMBER = "+11234567890" 9 | 10 | 11 | # Parse the Hangouts data and output Titanium Backup XML 12 | hangouts_parser = HangoutsParser() 13 | titanium_output = TitaniumBackupFormatter() 14 | print("Parsing Hangouts data file...") 15 | conversations, self_gaia_id = hangouts_parser.parse_input_file(HANGOUTS_JSON_FILE, YOUR_PHONE_NUMBER) 16 | print("Done.") 17 | print("Converting to SMS export file...") 18 | titanium_output.create_output_file(conversations, self_gaia_id, OUTPUT_FILE) 19 | print("Done.") 20 | -------------------------------------------------------------------------------- /conversation.py: -------------------------------------------------------------------------------- 1 | class Conversation: 2 | """SMS or MMS conversation. 3 | 4 | Model that describes a thread of SMS or MMS messages and participants. 5 | """ 6 | network_types = None # SMS/MMS seem to use PHONE 7 | active_timestamp = None # Not used 8 | self_latest_read_timestamp = None # Not used 9 | participants = None 10 | messages = None 11 | 12 | def __init__(self, network_types=None, participants=None, active_timestamp=None, 13 | self_read_timestamp=None, messages=None): 14 | self.network_types = network_types 15 | self.active_timestamp = active_timestamp 16 | self.self_latest_read_timestamp = self_read_timestamp 17 | self.participants = participants 18 | self.messages = messages 19 | -------------------------------------------------------------------------------- /attachment.py: -------------------------------------------------------------------------------- 1 | class Attachment: 2 | """MMS message attachment. 3 | 4 | Model that describes an attachment to an MMS message. 5 | """ 6 | album_id = None # Google Photos ID (not used) 7 | photo_id = None # Google Photos ID (not used) 8 | media_type = None # Type of attachment: PHOTO, ANIMATED_PHOTO, VIDEO 9 | original_content_url = None 10 | download_url = None # Not used, since it doesn't seem to work 11 | 12 | def __init__(self, album_id=None, photo_id=None, media_type=None, original_content_url=None, download_url=None): 13 | self.album_id = album_id 14 | self.photo_id = photo_id 15 | self.media_type = media_type 16 | self.original_content_url = original_content_url 17 | self.download_url = download_url 18 | -------------------------------------------------------------------------------- /message.py: -------------------------------------------------------------------------------- 1 | class Message: 2 | """SMS or MMS message. 3 | 4 | Model that describes a message. 5 | """ 6 | sender_gaia_id = None # This seems to be the ID to use 7 | sender_chat_id = None # Not used 8 | timestamp = None 9 | medium_type = None # Not used 10 | event_type = None # Not used 11 | content = None # Message body 12 | attachments = None # MMS attachments 13 | 14 | def __init__(self, sender_gaia_id=None, sender_chat_id=None, timestamp=None, 15 | medium_type=None, event_type=None, content=None, attachments=None): 16 | self.sender_gaia_id = sender_gaia_id 17 | self.sender_chat_id = sender_chat_id 18 | self.timestamp = timestamp 19 | self.medium_type = medium_type 20 | self.event_type = event_type 21 | self.content = content 22 | self.attachments = attachments 23 | -------------------------------------------------------------------------------- /participant.py: -------------------------------------------------------------------------------- 1 | class Participant: 2 | """Participant in a conversation. 3 | 4 | Model that describes a person in a conversation. 5 | """ 6 | name = None 7 | gaia_id = None 8 | chat_id = None # Not used 9 | type = None # Not used 10 | e164_number = None 11 | country_code = None # Not used 12 | international_number = None 13 | national_number = None 14 | region_code = None # Not used 15 | latest_read_timestamp = None # Not used 16 | 17 | def __init__(self, name=None, gaia_id=None, chat_id=None, type=None, e164_number=None, country_code=None, 18 | international_number=None, national_number=None, region_code=None, latest_timestamp=None): 19 | self.name = name 20 | self.gaia_id = gaia_id 21 | self.chat_id = chat_id 22 | self.type = type 23 | self.e164_number = e164_number 24 | self.country_code = country_code 25 | self.international_number = international_number 26 | self.national_number = national_number 27 | self.region_code = region_code 28 | self.latest_read_timestamp = latest_timestamp 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Aaron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | .venv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # PyCharm project settings 94 | .idea 95 | 96 | # Hangouts data files 97 | *.json 98 | 99 | # XML output files 100 | *.xml 101 | 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Hangouts to SMS/MMS 2 | ====== 3 | 4 | ### Note: This is not currently maintained and reportedly no longer works as-is. 5 | 6 | Scripts to convert exported Hangouts SMS/MMS messages to an SMS XML file. 7 | This is intended for Project Fi users that have been using Hangouts for texting along with the 8 | "Project Fi calls and SMS" option enabled who want to switch back to normal SMS/MMS apps. 9 | 10 | These scripts and steps will allow you to export your Project Fi SMS/MMS messages from the Hangouts "cloud" and import them into the normal SMS/MMS database on your phone, thus retaining your message history. 11 | 12 | ## Liability, etc 13 | **Use at your own risk!** I claim no responsibility for the results of using these scripts or the steps outlined below. I do not provide any warranty or support. 14 | 15 | ## Requirements: 16 | * Python 3 17 | * Titanium Backup **PRO** 18 | 19 | ## Notes: 20 | * Titanium backup will overwrite the existing SMS/MMS database on the phone. 21 | * Root is not required 22 | 23 | ## Phone Setup: 24 | 1. Install Titanium Backup **PRO** (root is **not** required for SMS/MMS backup/restore) 25 | 2. Backup existing SMS/MMS messages using Titanium backup: 26 | * Open Titanium Backup (dismiss the message about Root if it appears) 27 | * Press 'MENU' at the top right 28 | * Scroll down and select 'Backup data to XML...' 29 | * Select 'Messages (SMS & MMS)' 30 | * When it's finished, select 'Save file locally', and hit 'SAVE' (remember where you saved it) 31 | 3. Clear existing data from your SMS app: 32 | * Go into Settings and select 'Apps' 33 | * Select your preferred SMS app (e.g., Messenger or Textra) 34 | * Select 'Storage' 35 | * Select 'CLEAR DATA' 36 | 37 | ## Export Hangouts Messages: 38 | 1. Open https://takeout.google.com/settings/takeout 39 | 2. Under 'Select data to include', click the 'Select none' button 40 | 3. Scroll down and enable only Hangouts 41 | 4. Scroll down and click 'Next' 42 | 5. Increase 'Archive size (max)' to 4GB (hopefully not necessary) 43 | 6. Choose 'Delivery method' and click 'Create archive' 44 | 7. Download it to your computer when it's finished 45 | 46 | ## Converting Hangouts to Titanium Backup XML: 47 | 1. Edit "YOUR_PHONE_NUMBER" variable in hangouts_to_sms.py to contain your cell number. 48 | * This is because the number seems to be missing from some conversations. 49 | 2. Extract the Hangouts archive and copy the Hangouts.json file to the same folder as the script. 50 | 2. Run the hangouts_to_sms.py script 51 | 52 | ## Importing XML using Titanium Backup: 53 | 1. Copy the messages.xml output file to your phone (I used Google Drive to transfer it) 54 | 2. Open Titanium Backup 55 | 3. Press 'MENU' at the top right 56 | 4. Scroll down and select 'Restore data from XML...' 57 | 5. Select 'Messages (SMS & MMS)' 58 | 6. Select the messages.xml file from wherever you saved it on your phone 59 | 7. If prompted to set Titanium Backup as your default SMS app, allow it 60 | * It will change it back after it is finished 61 | 8. When it's finished, open your texting app of choice and wait a bit for it to parse through the new messages 62 | -------------------------------------------------------------------------------- /hangouts_parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | from argparse import Namespace 3 | from participant import Participant 4 | from message import Message 5 | from conversation import Conversation 6 | from attachment import Attachment 7 | 8 | 9 | class HangoutsParser: 10 | """Parses the Google Takeout JSON export for Hangouts SMS/MMS messages.""" 11 | 12 | def parse_input_file(self, hangouts_file_name, user_phone_number): 13 | """Parse the Hangouts JSON file containing SMS/MMS messages. 14 | 15 | :param hangouts_file_name: filename of the Hangouts messages 16 | :param user_phone_number: phone number of the user (some messages are missing this) 17 | :return: list of Conversation objects, GAIA ID of the user 18 | """ 19 | conversations = [] 20 | self_gaia_id = None # gaia_id for the phone owner 21 | with open(hangouts_file_name, 'rb') as data_file: 22 | # Read the Hangouts JSON file and turn into objects 23 | data = json.load(data_file, object_hook=lambda d: Namespace(**d)) 24 | # Iterate through each conversation in the list 25 | for conversation_state in data.conversation_state: 26 | # Get the nested conversation_state 27 | state = getattr(conversation_state, "conversation_state", None) 28 | if state is not None: 29 | # Get the conversation object 30 | conversation = getattr(state, "conversation", None) 31 | if conversation is None: 32 | continue 33 | # Create a new conversation and store its properties 34 | current_conversation = Conversation() 35 | current_conversation.network_types = getattr(conversation, "network_type", None) 36 | self_conversation_state = getattr(conversation, "self_conversation_state", None) 37 | if self_conversation_state is not None: 38 | current_conversation.active_timestamp = self._try_int_attribute(self_conversation_state, 39 | "active_timestamp") 40 | self_read_state = getattr(self_conversation_state, "self_read_state", None) 41 | if self_read_state is not None: 42 | current_conversation.self_latest_read_timestamp = \ 43 | self._try_int_attribute(self_read_state, "latest_read_timestamp") 44 | participant_id = getattr(self_read_state, "participant_id", None) 45 | if participant_id is not None: 46 | current_self_gaia_id = self._try_int_attribute(participant_id, "gaia_id") 47 | if current_self_gaia_id is not None: 48 | self_gaia_id = current_self_gaia_id 49 | # Get the conversation participants 50 | participant_data = getattr(conversation, "participant_data", None) 51 | read_state = getattr(conversation, "read_state", None) 52 | if participant_data is not None: 53 | current_conversation.participants = self._extract_participants(conversation.participant_data, 54 | read_state, user_phone_number, 55 | self_gaia_id) 56 | # Get the conversation messages 57 | events = getattr(state, "event", None) 58 | if events is not None: 59 | current_conversation.messages = self._process_messages(events) 60 | conversations.append(current_conversation) 61 | return conversations, self_gaia_id 62 | 63 | def _extract_participants(self, participant_data, read_state, user_phone_number, self_gaia_id): 64 | # Builds a dictionary of the participants in a conversation/thread 65 | participant_list = {} 66 | for participant in participant_data: 67 | # Create a new participant and store its properties 68 | current_participant = Participant() 69 | current_participant.name = getattr(participant, "fallback_name", None) 70 | participant_id = getattr(participant, "id", None) 71 | current_participant.chat_id = self._try_int_attribute(participant_id, "chat_id") 72 | current_participant.gaia_id = self._try_int_attribute(participant_id, "gaia_id") 73 | current_participant.type = getattr(participant, "participant_type", None) 74 | # Parse participant phone details 75 | phone_number = getattr(participant, "phone_number", None) 76 | if phone_number is not None: 77 | current_participant.e164_number = getattr(phone_number, "e164", None) 78 | i18n_data = getattr(phone_number, "i18n_data", None) 79 | if i18n_data is not None: 80 | current_participant.country_code = getattr(i18n_data, "country_code", None) 81 | current_participant.international_number = getattr(i18n_data, "international_number", None) 82 | current_participant.national_number = getattr(i18n_data, "national_number", None) 83 | current_participant.region_code = getattr(i18n_data, "region_code", None) 84 | # Sometimes the phone number is missing... 85 | # This only seems to happen for the user, not others 86 | if (current_participant.gaia_id is not None 87 | and current_participant.gaia_id == self_gaia_id 88 | and (current_participant.e164_number is None 89 | and current_participant.international_number is None 90 | and current_participant.national_number is None)): 91 | current_participant.e164_number = user_phone_number 92 | current_participant.international_number = user_phone_number 93 | participant_list[current_participant.gaia_id] = current_participant 94 | # Parse read_state to get latest_read_timestamp for each participant 95 | if read_state is not None: 96 | for participant_read_state in read_state: 97 | participant_id = getattr(participant_read_state, "participant_id", None) 98 | gaia_id = self._try_int_attribute(participant_id, "gaia_id") 99 | latest_read_timestamp = self._try_int_attribute(participant_read_state, "latest_read_timestamp") 100 | if gaia_id in participant_list.keys(): 101 | participant_list[gaia_id].latest_read_timestamp = latest_read_timestamp 102 | return participant_list 103 | 104 | def _process_messages(self, events): 105 | # Parses events/messages in a conversation 106 | message_list = [] 107 | for event in events: 108 | # Create new message and store its properties 109 | current_message = Message() 110 | sender_id = getattr(event, "sender_id", None) 111 | current_message.sender_gaia_id = self._try_int_attribute(sender_id, "gaia_id") 112 | current_message.sender_chat_id = self._try_int_attribute(sender_id, "chat_id") 113 | current_message.timestamp = self._try_int_attribute(event, "timestamp") 114 | delivery_medium = getattr(event, "delivery_medium", None) 115 | if delivery_medium is not None: 116 | current_message.medium_type = getattr(delivery_medium, "medium_type", None) 117 | current_message.event_type = getattr(event, "event_type", None) 118 | # Parse message chat content 119 | chat_message = getattr(event, "chat_message", None) 120 | if chat_message is not None: 121 | message_content = getattr(chat_message, "message_content", None) 122 | if message_content is not None: 123 | if hasattr(message_content, "segment"): 124 | current_message.content = self._process_message_content(message_content.segment) 125 | if hasattr(message_content, "attachment"): 126 | current_message.attachments = self._process_message_attachments(message_content.attachment) 127 | message_list.append(current_message) 128 | return message_list 129 | 130 | @staticmethod 131 | def _process_message_content(segment_list): 132 | # Parse the content/body of a message 133 | message_content = "" 134 | for current_segment in segment_list: 135 | if hasattr(current_segment, "formatting"): 136 | # TODO: FORMATTING tag handling 137 | # formatting 138 | # bold = boolean 139 | # italics = boolean 140 | # strikethrough = boolean 141 | # underline = boolean 142 | pass 143 | if hasattr(current_segment, "type"): 144 | if hasattr(current_segment, "text"): 145 | message_content += current_segment.text 146 | 147 | if current_segment.type == "TEXT": 148 | pass 149 | elif current_segment.type == "LINE_BREAK": 150 | pass 151 | elif current_segment.type == "LINK": 152 | # TODO: LINK type handling 153 | # link_data 154 | # display_url = string 155 | # link_target = string 156 | pass 157 | else: 158 | print("Error: Unknown message content TYPE: " + current_segment.type) 159 | continue 160 | else: 161 | print("Error: Message content missing TYPE!") 162 | continue 163 | return message_content 164 | 165 | def _process_message_attachments(self, attachment_list): 166 | # Parse the attachments of an MMS message 167 | attachments = [] 168 | for current_attachment in attachment_list: 169 | embed_item = getattr(current_attachment, "embed_item", None) 170 | if embed_item is not None: 171 | plus_photo = getattr(embed_item, "embeds.PlusPhoto.plus_photo", None) 172 | if plus_photo is not None: 173 | current_attachment = Attachment() 174 | current_attachment.album_id = self._try_int_attribute(plus_photo, "album_id") 175 | current_attachment.photo_id = self._try_int_attribute(plus_photo, "photo_id") 176 | current_attachment.media_type = getattr(plus_photo, "media_type", None) 177 | current_attachment.original_content_url = getattr(plus_photo, "original_content_url", None) 178 | current_attachment.download_url = getattr(plus_photo, "download_url", None) 179 | attachments.append(current_attachment) 180 | return attachments 181 | 182 | @staticmethod 183 | def _try_int_attribute(obj, attribute_name): 184 | # Return the integer value in the object with the specified name 185 | # If it cannot be cast as an int, return whatever it actually is 186 | result = None 187 | if obj is not None and attribute_name is not None: 188 | temp_attr = getattr(obj, attribute_name, None) 189 | if temp_attr is not None: 190 | try: 191 | result = int(temp_attr) 192 | except ValueError: 193 | result = temp_attr 194 | return result 195 | -------------------------------------------------------------------------------- /titanium_backup_formatter.py: -------------------------------------------------------------------------------- 1 | import urllib.request 2 | import base64 3 | import uuid 4 | import os 5 | from datetime import datetime 6 | from xml.sax.saxutils import escape 7 | 8 | 9 | # XML output constants for Titanium Backup 10 | SMS_OUTPUT_HEADER_1 = "" 11 | SMS_OUTPUT_HEADER_2 = "" 12 | MMS_PART = "{}" 13 | 14 | 15 | class TitaniumBackupFormatter: 16 | """Converts parsed Hangouts SMS/MMS messages from HangoutsParser for use with Titanium Backup""" 17 | 18 | def create_output_file(self, conversations, self_gaia_id, output_file_name): 19 | """Creates an XML file containing SMS/MMS that can be used in Titanium Backup. 20 | 21 | :param conversations: list of Conversation objects 22 | :param self_gaia_id: GAIA ID of the user 23 | :param output_file_name: name of the output XML file 24 | :return: 25 | """ 26 | try: 27 | os.remove(output_file_name) 28 | except OSError: 29 | pass 30 | with open(output_file_name, 'w') as sms_output: 31 | sms_output.write(SMS_OUTPUT_HEADER_1) 32 | sms_output.write(SMS_OUTPUT_HEADER_2.format(len(conversations))) 33 | for conversation in conversations: 34 | # Skip non-SMS conversations 35 | if "PHONE" not in conversation.network_types: 36 | continue 37 | sms_output.write("".format( 38 | self._create_participant_string(conversation.participants, self_gaia_id))) 39 | for message in conversation.messages: 40 | if message.sender_gaia_id is None: 41 | print("Error: message sender gaia ID is None!") 42 | continue 43 | if message.sender_gaia_id not in conversation.participants.keys(): 44 | print("Error: could not match sender gaia ID to participant IDs!") 45 | continue 46 | is_sms = len(conversation.participants) <= 2 and message.attachments is None 47 | is_sent = message.sender_gaia_id == self_gaia_id 48 | message_timestamp = self._timestamp_to_utc_string(message.timestamp) 49 | if is_sms: 50 | # Store the other participant in the SMS conversation 51 | non_self_participant = None 52 | for participant in conversation.participants.values(): 53 | if participant.gaia_id != self_gaia_id: 54 | non_self_participant = participant 55 | break 56 | # start of sms 57 | message_string = "" 101 | 102 | # addresses 103 | message_string += "" 104 | if is_sent: 105 | message_string += "
insert-address-token
" 106 | else: 107 | sender = conversation.participants[message.sender_gaia_id] 108 | message_string += "
{}
".format( 109 | self._get_participant_phone_number(sender)) 110 | # Store the other participants 111 | for participant in conversation.participants.values(): 112 | if participant.gaia_id != self_gaia_id and participant.gaia_id != message.sender_gaia_id: 113 | message_string += "
{}
".format( 114 | "from" if participant.gaia_id == message.sender_gaia_id else "to", 115 | self._get_participant_phone_number(participant)) 116 | message_string += "
" 117 | 118 | # parts 119 | order = 0 120 | if message.content is not None: 121 | content_is_plain = self._is_ascii(message.content) 122 | message_string += MMS_PART.format("text/plain", 123 | order, 124 | "plain" if content_is_plain 125 | else "base64", 126 | escape(message.content) if content_is_plain 127 | else self._base64_text(message.content)) 128 | order += 1 129 | if message.attachments is not None and len(message.attachments) > 0: 130 | for attachment in message.attachments: 131 | if attachment.media_type is not None: 132 | if attachment.media_type == "PHOTO": 133 | data = None 134 | if attachment.original_content_url is not None: 135 | data = self._convert_url_to_base64_data(attachment.original_content_url) 136 | if data is not None: 137 | message_string += MMS_PART.format("image/jpeg", order, "base64", data) 138 | order += 1 139 | else: 140 | print("Error: unable to download image data!") 141 | elif attachment.media_type == "ANIMATED_PHOTO": 142 | data = None 143 | if attachment.original_content_url is not None: 144 | data = self._convert_url_to_base64_data(attachment.original_content_url) 145 | if data is not None: 146 | message_string += MMS_PART.format("image/gif", order, "base64", data) 147 | order += 1 148 | else: 149 | print("Error: unable to download image data!") 150 | elif attachment.media_type == "VIDEO": 151 | data = None 152 | if attachment.original_content_url is not None: 153 | data = self._convert_url_to_base64_data(attachment.original_content_url) 154 | if data is not None: 155 | message_string += MMS_PART.format("video/*", order, "base64", data) 156 | order += 1 157 | else: 158 | print("Error: unable to download video data!") 159 | else: 160 | print("Error: Attachment media type is unknown!") 161 | else: 162 | print("Error: Attachment media type is unspecified!") 163 | message_string += "" 164 | sms_output.write(message_string) 165 | 166 | sms_output.write("
") 167 | sms_output.write("
") 168 | sms_output.close() 169 | 170 | @staticmethod 171 | def _is_ascii(text): 172 | # Returns true if the text only contains ASCII characters 173 | return len(text) == len(text.encode()) 174 | 175 | @staticmethod 176 | def _base64_text(text): 177 | # Converts the unicode text to base64 178 | return base64.b64encode(bytes(text, "utf-8")).decode('utf-8') 179 | 180 | @staticmethod 181 | def _convert_url_to_base64_data(url): 182 | # Downloads a file and converts it to base64 183 | encoded_data = None 184 | if url is not None: 185 | file_name = 'tmp/' + str(uuid.uuid4()) 186 | os.makedirs(os.path.dirname(file_name), exist_ok=True) 187 | with urllib.request.urlopen(url) as file: 188 | data = file.read() 189 | with open(file_name, "wb") as new_file: 190 | new_file.write(data) 191 | new_file.close() 192 | encoded_data = base64.b64encode(open(file_name, "rb").read()).decode('utf-8') 193 | os.remove(file_name) 194 | if encoded_data is None or len(encoded_data) <= 0: 195 | print("Error downloading or base64 encoding attachment!") 196 | return encoded_data 197 | 198 | def _create_participant_string(self, participants, self_gaia_id): 199 | # Builds a string containing the participants in a conversation, excluding the user 200 | result = "" 201 | for participant in participants.values(): 202 | # Do not include self in the list of participants 203 | if participant.gaia_id == self_gaia_id: 204 | continue 205 | number = self._get_participant_phone_number(participant) 206 | if number is not None: 207 | result += number + ";" 208 | if len(result) <= 0: 209 | return None 210 | return result.rstrip(';') 211 | 212 | @staticmethod 213 | def _get_participant_phone_number(participant): 214 | # Returns the phone number for a Participant 215 | number = None 216 | if participant.national_number is not None: 217 | number = participant.national_number 218 | elif participant.e164_number is not None: 219 | number = participant.e164_number 220 | elif participant.international_number is not None: 221 | number = participant.international_number 222 | return number 223 | 224 | @staticmethod 225 | def _timestamp_to_utc_string(timestamp): 226 | # Converts microsecond timestamp to UTC string 227 | (dt, microseconds) = datetime.utcfromtimestamp(timestamp / 1000000).strftime('%Y-%m-%dT%H:%M:%S.%f').split('.') 228 | return "%s.%03dZ" % (dt, int(microseconds) / 1000) 229 | --------------------------------------------------------------------------------