├── Dockerfile ├── Makefile ├── README.md └── signal-message-exporter.py /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | 3 | RUN apt-get update 4 | RUN apt-get install -y git 5 | RUN apt-get install -y g++ 6 | RUN apt-get install -y libssl-dev 7 | RUN apt-get install -y libsqlite3-dev 8 | RUN apt-get install -y build-essential 9 | RUN apt-get install -y python3 10 | RUN apt-get install -y python3-setuptools 11 | 12 | RUN git clone https://github.com/bepaald/signalbackup-tools 13 | RUN cd signalbackup-tools && ./BUILDSCRIPT.sh 14 | RUN mv signalbackup-tools/signalbackup-tools /usr/bin/ && chmod a+x /usr/bin/signalbackup-tools 15 | RUN rm -rf signalbackup-tools 16 | 17 | WORKDIR /root 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | DOCKER := docker run -e SIG_KEY -e SIG_FILE -it -v $${PWD}:/root/:z workspace 3 | 4 | run: 5 | docker build -t workspace . 6 | $(DOCKER) python3 signal-message-exporter.py 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # signal-message-exporter 2 | 3 | Take a Signal encrypted backup file, decrypt it and export its contents to an 4 | XML file that can be used with SyncTech's *SMS Backup & Restore* Android app. 5 | 6 | This project will export all SMS + MMS + Signal messages, so that all the 7 | messages can be re-imported into the Android messaging store. 8 | 9 | 10 | ## Caveats 11 | 12 | * Tested on Docker, Linux and for Android 13 | * Also tested on macos, if you get Error 137, you may need to bump up memory and swap in docker's settings 14 | 15 | 16 | ## Instructions 17 | 18 | 1. Generate a Signal backup file 19 | 20 | ``` 21 | Signal -> Chats -> Backups -> Local Backup 22 | ``` 23 | 24 | 2. Transfer that file to your computer, file will be named eg: signal-2022-06-10-17-00-00.backup 25 | 26 | 3. Download this repo and run: 27 | 28 | ``` 29 | cd signal-message-exporter 30 | export SIG_KEY=123451234512345123451234512345 31 | export SIG_FILE=signal-2022-06-10-17-00-00.backup 32 | make run 33 | ``` 34 | 35 | 4. A new XML file should be generated, transfer the XML file back to your phone. 36 | 37 | 5. Run SyncTech's *SMS Backup & Restore* to import the XML file. 38 | 39 | 6. Check to see if all your messages imported into Android ok. If not, create a PR which fixes the problem ;) 40 | 41 | 42 | ## Windows instructions 43 | 44 | I haven't tested this on Windows, but user @jbaker6953 has supplied some Windows instructions in 45 | https://github.com/alexlance/signal-message-exporter/issues/10 46 | 47 | and there's a reddit comment here that goes into it: 48 | https://old.reddit.com/r/signal/comments/y7d3gj/having_trouble_with_smsmms_export_the_devs_want/iutmwr7/ 49 | 50 | 51 | ## Thoughts 52 | * Feel free to shout out with any issues problems in github issues 53 | * Make sure to go and give signalbackup-tools some kudos as they do most of the heavy lifting 54 | -------------------------------------------------------------------------------- /signal-message-exporter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import sqlite3 4 | import logging 5 | import argparse 6 | import xml.dom.minidom 7 | import xml.dom # for monkeypatch 8 | import base64 9 | from shutil import which, rmtree # noqa 10 | 11 | 12 | def _write_data(writer, data, isAttrib=False): 13 | "Writes datachars to writer." 14 | # Patch minidom for unencoded attributes: 15 | # https://github.com/python/cpython/issues/50002 16 | # The monkey patch included on that bug report is quite old and doesn't work 17 | # with today's xml.dom/minidom code. The code below has been updated to be 18 | # compatible with the latest xml.dom.minidom codebase. 19 | if data: 20 | data = data.replace("&", "&").replace("<", "<").replace("\"", """).replace(">", ">") 21 | if isAttrib: 22 | data = data.replace("\r", " ").replace("\n", " ").replace("\t", " ") 23 | writer.write(data) 24 | xml.dom.minidom._write_data = _write_data # noqa 25 | 26 | 27 | def writexml(self, writer, indent="", addindent="", newl=""): 28 | """Write an XML element to a file-like object 29 | Write the element to the writer object that must provide 30 | a write method (e.g. a file or StringIO object). 31 | """ 32 | # indent = current indentation 33 | # addindent = indentation to add to higher levels 34 | # newl = newline string 35 | writer.write(indent + "<" + self.tagName) 36 | attrs = self._get_attributes() 37 | 38 | for a_name in attrs.keys(): 39 | writer.write(" %s=\"" % a_name) 40 | _write_data(writer, attrs[a_name].value, isAttrib=True) 41 | writer.write("\"") 42 | if self.childNodes: 43 | writer.write(">") 44 | if (len(self.childNodes) == 1 and self.childNodes[0].nodeType in (xml.dom.Node.TEXT_NODE, xml.dom.Node.CDATA_SECTION_NODE)): 45 | self.childNodes[0].writexml(writer, '', '', '') 46 | else: 47 | writer.write(newl) 48 | for node in self.childNodes: 49 | node.writexml(writer, indent + addindent, addindent, newl) 50 | writer.write(indent) 51 | writer.write("%s" % (self.tagName, newl)) 52 | else: 53 | writer.write("/>%s" % (newl)) 54 | xml.dom.minidom.Element.writexml = writexml # noqa 55 | 56 | 57 | def run_cmd(cmd): 58 | logging.info(f"running command: {cmd}") 59 | r = os.popen(cmd) 60 | logging.info(r.read()) 61 | rtn = r.close() 62 | if rtn is not None: 63 | logging.error(f"command failed: {cmd}") 64 | sys.exit(rtn) 65 | 66 | 67 | def print_num_sms(): 68 | q = "select count(*) as tally from message where type in (20, 22, 23, 24, 87, 88) and m_type = 0" 69 | cursor.execute(q) 70 | (tally,) = cursor.fetchone() 71 | logging.info(f"Total num SMS messages: {tally}") 72 | 73 | 74 | def print_num_signal(): 75 | q = "select count(*) as tally from message where type in (10485780, 10485783, 10485784) and m_type = 0" 76 | cursor.execute(q) 77 | (tally,) = cursor.fetchone() 78 | logging.info(f"Total number Signal messages: {tally}") 79 | 80 | 81 | def print_num_mms(): 82 | q = "select count(*) as tally from message where type in (20, 22, 23, 24, 87, 88) and m_type in (128, 130, 132)" 83 | cursor.execute(q) 84 | (tally,) = cursor.fetchone() 85 | logging.info(f"Total num MMS messages: {tally}") 86 | 87 | 88 | def print_num_signal_mms(): 89 | q = "select count(*) as tally from message where type in (10485780, 10485783, 10485784) and m_type in (128, 130, 132)" 90 | cursor.execute(q) 91 | (tally,) = cursor.fetchone() 92 | logging.info(f"Total number Signal media messages: {tally}") 93 | 94 | 95 | def get_recipients(): 96 | cursor.execute("select e164 as phone, system_joined_name as system_display_name, _id, pni from recipient") 97 | contacts_by_id = {} 98 | for c in cursor.fetchall(): 99 | c = dict(c) 100 | if 'phone' in c and c['phone']: 101 | clean_number = c["phone"].replace("-", "").replace(" ", "").replace("(", "").replace(")", "") 102 | contacts_by_id[c['_id']] = {'phone': clean_number, 'name': c['system_display_name'], 'recipient_id': c['_id'], 'pni': c['pni']} 103 | return contacts_by_id 104 | 105 | 106 | def get_groups(): 107 | cursor.execute("select group_id, recipient_id from groups") 108 | groups_by_id = {} 109 | for g in cursor.fetchall(): 110 | g = dict(g) 111 | cursor.execute(f"SELECT recipient_id FROM group_membership WHERE group_membership.group_id IS \"{g['group_id']}\"") 112 | for member in cursor.fetchall(): 113 | if g['recipient_id'] not in groups_by_id: 114 | groups_by_id[g['recipient_id']] = [] 115 | try: 116 | groups_by_id[g['recipient_id']].append(ADDRESSES[int(member['recipient_id'])]) 117 | except KeyError: 118 | logging.info(f"Unable to find a contact on your phone with ID: {member['recipient_id']}") 119 | return groups_by_id 120 | 121 | 122 | def xml_create_sms(root, row, addrs): 123 | sms = root.createElement('sms') 124 | sms.setAttribute('protocol', '0') 125 | sms.setAttribute('subject', 'null') 126 | sms.setAttribute('date', str(row['date_sent'])) 127 | sms.setAttribute('service_center', 'null') 128 | sms.setAttribute('toa', 'null') 129 | sms.setAttribute('sc_toa', 'null') 130 | sms.setAttribute('read', '1') 131 | sms.setAttribute('status', '-1') 132 | 133 | phone = "" 134 | name = "" 135 | tilda = "" 136 | space = "" 137 | 138 | if addrs and len(addrs): 139 | for p in addrs: 140 | if "phone" in p and p["phone"]: 141 | phone += tilda + str(p["phone"]) 142 | tilda = "~" 143 | if "name" in p and p["name"]: 144 | name += space + str(p["name"]) 145 | space = ", " 146 | 147 | sms.setAttribute('address', phone) 148 | sms.setAttribute('contact_name ', name) 149 | 150 | try: 151 | t = TYPES[int(row['type'])] 152 | except KeyError: 153 | t = 1 # default to received 154 | sms.setAttribute('type', str(t)) 155 | sms.setAttribute('body', str(row.get('body', ''))) 156 | return sms 157 | 158 | 159 | def xml_create_mms(root, row, parts, addrs): 160 | mms = root.createElement('mms') 161 | partselement = root.createElement('parts') 162 | addrselement = root.createElement('addrs') 163 | mms.setAttribute('date', str(row["date_sent"])) 164 | mms.setAttribute('ct_t', "application/vnd.wap.multipart.related") 165 | 166 | # type - The type of message, 1 = Received, 2 = Sent, 3 = Draft, 4 = Outbox 167 | try: 168 | t = TYPES[int(row.get('type', 20))] 169 | except KeyError: 170 | t = 1 171 | mms.setAttribute('msg_box', str(t)) 172 | mms.setAttribute('rr', 'null') 173 | mms.setAttribute('sub', 'null') 174 | mms.setAttribute('read_status', '1') 175 | 176 | phone = "" 177 | name = "" 178 | tilda = "" 179 | space = "" 180 | 181 | if addrs and len(addrs): 182 | for p in addrs: 183 | if "phone" in p and p["phone"]: 184 | phone += tilda + str(p["phone"]) 185 | tilda = "~" 186 | if "name" in p and p["name"]: 187 | name += space + str(p["name"]) 188 | space = ", " 189 | 190 | mms.setAttribute('address', phone) 191 | mms.setAttribute('contact_name ', name) 192 | mms.setAttribute('m_id', 'null') 193 | mms.setAttribute('read', '1') 194 | mms.setAttribute('m_size', str(row['m_size'])) 195 | mms.setAttribute('m_type', str(row['m_type'])) 196 | mms.setAttribute('sim_slot', '0') 197 | 198 | if parts or (row['body'] and row['body'] != 'null'): 199 | mms.appendChild(partselement) 200 | if str(row['body']).startswith('BEGIN:VCARD'): 201 | vcardencoding = base64.b64encode(row['body'].encode()).decode() 202 | partselement.appendChild(xml_create_vcard_part(root, vcardencoding)) 203 | elif row['body'] and row['body'] != 'null': 204 | partselement.appendChild(xml_create_mms_text_part(root, str(row['body']))) 205 | if parts: 206 | for part in parts: 207 | try: 208 | partselement.appendChild(xml_create_mms_part(root, part)) 209 | except Exception as e: 210 | logging.error(f"Bad: {e} for {part}") 211 | continue 212 | if addrs: 213 | mms.appendChild(addrselement) 214 | 215 | for addr in addrs: 216 | # The type of address, 129 = BCC, 130 = CC, 151 = To, 137 = From 217 | # group alex, ben, meg: alex sends message, alex=From, ben and meg=To 218 | # type - The type of message, 1 = Received, 2 = Sent, 3 = Draft, 4 = Outbox 219 | if row["recipient_id"] == addr["recipient_id"] and t == 1: 220 | type_address = 137 221 | elif row['recipient_id'] == row['receiver'] and addr["pni"] and t != 1: 222 | type_address = 137 223 | else: 224 | type_address = 151 225 | addrselement.appendChild(xml_create_mms_addr(root, addr, type_address)) 226 | return mms 227 | 228 | 229 | def xml_create_mms_part(root, row): 230 | part = root.createElement('part') 231 | part.setAttribute("seq", str(row['seq'])) 232 | part.setAttribute("name", str(row['name'])) 233 | part.setAttribute("chset", str(row.get('chset', ''))) # gone? 234 | part.setAttribute("cl", str(row.get('cl', ''))) # gone? 235 | part.setAttribute("ct", str(row['ct'])) 236 | 237 | # seem to have lost the unique_id now too 238 | # filename = f"bits/Attachment_{row['_id']}_{row['unique_id']}.bin" 239 | filename = f"bits/Attachment_{row['_id']}_-1.bin" 240 | try: 241 | with open(filename, 'rb') as f: 242 | b = base64.b64encode(f.read()) 243 | base64_encoded_file_data = str(b.decode()) 244 | except FileNotFoundError: 245 | logging.error(f'File {filename} not found for part: {row["_id"]}') 246 | raise 247 | 248 | part.setAttribute("data", base64_encoded_file_data) 249 | return part 250 | 251 | 252 | def xml_create_mms_text_part(root, body): 253 | part = root.createElement('part') 254 | part.setAttribute("seq", "0") 255 | part.setAttribute("ct", "text/plain") 256 | part.setAttribute("chset", "UTF-8") 257 | part.setAttribute("text", body) 258 | return part 259 | 260 | 261 | def xml_create_vcard_part(root, vcarddata): 262 | if vcarddata: 263 | part = root.createElement('part') 264 | part.setAttribute("seq", "0") 265 | part.setAttribute("ct", "text/x-vCard") 266 | part.setAttribute("chset", "UTF-8") 267 | part.setAttribute("body", "null") 268 | part.setAttribute("data", vcarddata) 269 | return part 270 | 271 | 272 | def xml_create_mms_addr(root, address, address_type): 273 | addr = root.createElement('addr') 274 | addr.setAttribute("address", str(address['phone'])) 275 | addr.setAttribute("type", str(address_type)) 276 | addr.setAttribute("charset", "UTF-8") # todo 277 | return addr 278 | 279 | 280 | def is_tool(name): 281 | """Check whether `name` is on PATH and marked as executable.""" 282 | # from whichcraft import which 283 | return which(name) is not None 284 | 285 | 286 | def no_nones(row): 287 | for sNull in row: 288 | if row[sNull] is None: 289 | row[sNull] = 'null' 290 | return row 291 | 292 | 293 | parser = argparse.ArgumentParser(description='Export Signal messages to an XML file compatible with SMS Backup & Restore') 294 | # parser.add_argument('args', nargs='*') 295 | # parser.add_argument('--mode', '-m', dest='mode', action='store', help="mode should be one sms-only, sms-mms-only, sms-mms-signal") 296 | parser.add_argument('--verbose', '-v', dest='verbose', action='store_true', help='Make logging more verbose') 297 | args = parser.parse_args() 298 | if args.verbose: 299 | logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=logging.DEBUG) 300 | else: 301 | logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=logging.INFO) 302 | 303 | PLATFORM = sys.platform 304 | 305 | if PLATFORM == 'win32': 306 | BKP_TOOL = 'signalbackup-tools' 307 | elif PLATFORM in ['linux', 'linux2']: 308 | BKP_TOOL = '/usr/bin/signalbackup-tools' 309 | else: 310 | BKP_TOOL = None 311 | 312 | if not is_tool(BKP_TOOL): 313 | BKP_TOOL = input(r'Could not find signalbackup-tools, please input full path to executable: ') 314 | 315 | SIG_KEY = os.environ.get("SIG_KEY", '') 316 | SIG_FILE = os.environ.get("SIG_FILE", '') 317 | 318 | if not os.environ.get("SIG_KEY"): 319 | SIG_KEY = input("Could not find SIG_KEY environment variable, please input here: ") 320 | if not os.environ.get("SIG_FILE"): 321 | SIG_FILE = input(r"Could not find SIG_FILE environment variable, please input full path to Signal backupfile here: ") 322 | 323 | logging.info('Recreating temporary export dir') 324 | rmtree('bits', ignore_errors=True) 325 | os.makedirs('bits', exist_ok=True) 326 | try: 327 | os.remove('sms-backup-restore.xml') 328 | logging.info('Removed existing sms-backup-restore.xml') 329 | except FileNotFoundError: 330 | pass 331 | 332 | logging.info('Starting signalbackup-tools') 333 | run_cmd(f'{BKP_TOOL} --input {SIG_FILE} --output bits/ --password {SIG_KEY} --no-showprogress') 334 | logging.info('Finished signalbackup-tools') 335 | logging.info('Parsing the sqlite database bits/database.sqlite') 336 | 337 | # parse the sqlite database generated by github.com/bepaald/signalbackup-tools 338 | conn = sqlite3.connect(os.path.join("bits", "database.sqlite")) 339 | conn.row_factory = sqlite3.Row 340 | cursor = conn.cursor() 341 | cursor2 = conn.cursor() 342 | 343 | # types: 344 | # 1 = System notification of incoming Signal voice call 345 | # 2 = System notification of outgoing Signal voice call 346 | # 3 = System notification of missed incoming Signal voice call 347 | # 7 = System notification of contact's profile name change 348 | # 14 = System notification that a contact's Signal number has changed 349 | # 20 = Received SMS or MMS 350 | # 22 = Pending outgoing message that hasn't sent yet 351 | # 23 = Sent SMS or MMS message 352 | # 24 = SMS message that failed to send 353 | # 87 = Sent SMS or MMS message 354 | # 88 = Sent MMS message that failed to send 355 | # 8214 = System notification that contact's safety number was marked 'unverified' 356 | # 16406 = System notification that contact's safety number was marked 'verified' 357 | # 2097156 = System notification that a contact is now on Signal 358 | # 2097684 = System notification of safety number change 359 | # 8388628 = MMS received from Signal system (not from network). Includes media messages sent to self. 360 | # 10485780 = Received Signal message 361 | # 10485783 = Sent Signal message 362 | # 10485784 = Signal message that failed to send 363 | 364 | TYPES = { 365 | 22: 2, # me sent 366 | 23: 2, # me sent 367 | 24: 2, # me sent 368 | 87: 2, # me sent 369 | 88: 2, # me sent 370 | 10485783: 2, # me sent 371 | 10485784: 2, # me sent 372 | 10485780: 1, # received 373 | 20: 1, # received 374 | 11075607: 1, # received (?) 375 | } 376 | 377 | export_types = (20, 22, 23, 24, 87, 88, 8388628, 10485780, 10485783, 10485784) 378 | 379 | ADDRESSES = get_recipients() 380 | GROUPS = get_groups() 381 | 382 | print_num_sms() 383 | print_num_signal() 384 | print_num_mms() 385 | print_num_signal_mms() 386 | 387 | root = xml.dom.minidom.Document() 388 | smses = root.createElement('smses') 389 | root.appendChild(smses) 390 | 391 | sms_counter = 0 392 | sms_errors = 0 393 | mms_counter = 0 394 | mms_errors = 0 395 | signal_message_count = 0 396 | 397 | logging.info('Starting message export') 398 | 399 | cursor.execute("""select message._id, message.date_sent, message.m_size, message.m_type, message.body, 400 | message.to_recipient_id as recipient_id, message.type, message.story_type, thread.recipient_id as receiver 401 | from message left join thread on message.thread_id = thread._id order by message.date_sent desc""") 402 | 403 | for row in cursor.fetchall(): 404 | row = no_nones(dict(row)) 405 | logging.debug(f'Processing: {row["_id"]}') 406 | 407 | addrs = [] 408 | if row["receiver"] in GROUPS: 409 | addrs = GROUPS[row["receiver"]] 410 | elif row["receiver"] in ADDRESSES: 411 | addrs.append(ADDRESSES[row["receiver"]]) 412 | 413 | # m_types: 128 = MMS sent from user, 132 = MMS received by user, 414 | # 130 = MMS received by user but not downloaded from server, 415 | # 0 = SMS sent or received, null = ? 416 | if row["type"] in export_types and row["m_type"] in (128, 130, 132) and row["story_type"] == 0: 417 | mms_counter += 1 418 | parts = [] 419 | # they dropped the "part" table... 420 | # cursor2.execute(f"""select _id, seq, name, chset, cl, ct, unique_id from part 421 | # where mid = {row['_id']} order by seq""") 422 | cursor2.execute(f"""select _id, display_order as seq, file_name as name, content_type as ct 423 | from attachment where message_id = {row['_id']} order by display_order""") 424 | for part in cursor2.fetchall(): 425 | parts.append(no_nones(dict(part))) 426 | 427 | try: 428 | mmstest = smses.appendChild(xml_create_mms(root, row, parts, addrs)) 429 | 430 | # if mmstest.getElementsByTagName('parts') and mmstest.getElementsByTagName('parts')[0].childNodes == []: 431 | # # If we get here the parts element has no child nodes. Delete the whole mms. 432 | # # This is rare, but can happen with a blank MMS message with an attachment and 433 | # # when the attachment can't be found. 434 | # logging.error(f"Failed to export this mms: {row} because can't find parts: {len(parts)}") 435 | # mmstest.parentNode.removeChild(mmstest) 436 | # mms_errors += 1 437 | # mms_counter -= 1 438 | 439 | except Exception as e: 440 | logging.error(f"Failed to export this message: {row} because {e}") 441 | mms_errors += 1 442 | mms_counter -= 1 443 | raise 444 | 445 | elif row["type"] in export_types and (row["m_type"] == 0 or row["m_type"] == 'null') and row["story_type"] == 0: 446 | # No body in SMS means no message. Let's avoid creating empty messages. 447 | # Some system-generated messages in Signal (such as alerts that a user's 448 | # number has changed) have a null body. 449 | if row["body"] != 'null': 450 | sms_counter += 1 451 | try: 452 | smses.appendChild(xml_create_sms(root, row, addrs)) 453 | except Exception as e: 454 | logging.error(f"Failed to export this text message: {row} because {e}") 455 | sms_errors += 1 456 | sms_counter -= 1 457 | raise 458 | else: 459 | signal_message_count += 1 460 | logging.debug(f'Message ID {row["_id"]} skipped because it is an internal Signal message') 461 | logging.info("Finished export.") 462 | logging.info(f"""Messages exported: {sms_counter + mms_counter} SMS Errors: {sms_errors} MMS Errors: {mms_errors}. Skipped internal Signal messages: {signal_message_count}""") 463 | 464 | # update the total count 465 | smses.setAttribute("count", str(sms_counter + mms_counter)) 466 | 467 | with open("sms-backup-restore.xml", "w", encoding="utf-8") as f: 468 | root.writexml(f, indent="\t", addindent="\t", newl="\n", encoding="utf-8", standalone="yes") 469 | 470 | conn.commit() 471 | cursor.close() 472 | 473 | rmtree('bits', ignore_errors=True) 474 | logging.info("Complete.") 475 | logging.info("Created: sms-backup-restore.xml") 476 | logging.info("Now install SMS Backup & Restore and choose this file to restore") 477 | if int(sms_errors + mms_errors) > 0: 478 | logging.error(f"WARNING: {sms_errors + mms_errors} messages were skipped! I.e. Not all messages were exported successfully. See output above for the messages that were skipped") 479 | --------------------------------------------------------------------------------