├── .github └── workflows │ └── build.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── make_release.py ├── pyproject.toml ├── screenshot.png ├── setup.py ├── signal2html ├── __init__.py ├── __main__.py ├── __version__.py ├── addressbook.py ├── core.py ├── dbproto.py ├── exceptions.py ├── html.py ├── html_colors.py ├── linkify.py ├── models.py ├── templates │ └── thread.html ├── types.py ├── ui.py └── versioninfo.py └── tests ├── test_linkify.py └── test_versioninfo.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | python: 13 | name: Tests 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | # Can add others if deemed necessary 18 | os: [ 'ubuntu-latest' ] 19 | # minimal and latest 20 | py: [ '3.7', '3.11' ] 21 | 22 | steps: 23 | - name: Install Python ${{ matrix.py }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.py }} 27 | 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | 31 | - name: Run unit tests 32 | run: make test_direct 33 | 34 | - name: Run code quality tests (black) 35 | uses: psf/black@stable 36 | with: 37 | # keep in sync with .pre-commit-config.yaml 38 | version: "23.1.0" 39 | 40 | - name: Run code quality tests (isort) 41 | uses: jamescurtin/isort-action@master 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | */__pycache__ 2 | *.egg-info 3 | dist/ 4 | build/ 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.1.0 # keep in sync with Github Actions build.yml 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | 8 | - repo: https://github.com/pycqa/isort 9 | rev: 5.12.0 10 | hooks: 11 | - id: isort 12 | name: isort (python) 13 | - id: isort 14 | name: isort (cython) 15 | types: [cython] 16 | - id: isort 17 | name: isort (pyi) 18 | types: [pyi] 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 0.2.11 4 | 5 | * Better checking for database availability 6 | 7 | ## Version 0.2.10 8 | 9 | * Bump version of dependency package [emoji](https://pypi.org/project/emoji) 10 | 11 | ## Version 0.2.9 12 | 13 | * Fix bug in [#59](https://github.com/GjjvdBurg/signal2html/issues/59). Thanks 14 | to aweinberg38 for reporting this issue and providing a patch. 15 | 16 | ## Version 0.2.8 17 | 18 | * Code fixes for group update messages (thanks to @prgarnett and @aweinberg38 19 | for reporting [#37](https://github.com/GjjvdBurg/signal2html/issues/57)) 20 | 21 | ## Version 0.2.7 22 | 23 | * Code fixes for missing message data 24 | 25 | ## Version 0.2.6 26 | 27 | * Bugfix for handling mentions 28 | 29 | ## Version 0.2.5 30 | 31 | * Add support for clickable urls (thanks to @sbaum for reporting 32 | [#37](https://github.com/GjjvdBurg/signal2html/issues/37)) 33 | * Display more message metadata (thanks to @ericthegrey in 34 | [#33](https://github.com/GjjvdBurg/signal2html/pull/33)) 35 | * Add header to threads as in the app (thanks to @Aztorius for suggesting this 36 | in [#39](https://github.com/GjjvdBurg/signal2html/issues/39)) 37 | * Minor formatting fixes for thread messages 38 | 39 | ## Version 0.2.4 40 | 41 | * Bugfix for resolving recipient names for database version 108 and higher 42 | (thanks to @sbaum for reporting 43 | [#34](https://github.com/GjjvdBurg/signal2html/issues/34)). 44 | 45 | ## Version 0.2.3 46 | 47 | * Bugfix for non-existent recipients (thanks to @ericthegrey 48 | [#30](https://github.com/GjjvdBurg/signal2html/issues/30)). 49 | * Expand color palette to match Signal (thanks to @ChemoCosmo for reporting 50 | this (see [#30](https://github.com/GjjvdBurg/signal2html/issues/30)). 51 | * Support messages for video calls and key change (thanks to @ericthegrey 52 | [#29](https://github.com/GjjvdBurg/signal2html/pull/29)). 53 | 54 | ## Version 0.2.2 55 | 56 | * Add support for mentions (thanks to @ericthegrey) 57 | * Fixes for output filenames and directories 58 | * Minor cleanup 59 | 60 | ## Version 0.2.1 61 | 62 | * Improve version tests (thanks to @ericthegrey) 63 | * Add support for reactions (thanks to @ericthegrey) 64 | 65 | ## Version 0.2.0 66 | 67 | * Clean up code using an Addressbook class for recipients (thanks to 68 | @ericthegrey [#15](https://github.com/GjjvdBurg/signal2html/pull/15)). 69 | 70 | ## Version 0.1.8 71 | 72 | * Add a fallback to download attachment of that are not image/audio/video, and 73 | add support for showing stickers 74 | ([#11](https://github.com/GjjvdBurg/signal2html/pull/11) thanks to 75 | @ericthegrey) 76 | 77 | ## Version 0.1.7 78 | 79 | * Add phone field for quote author (bugfix) 80 | 81 | ## Version 0.1.6 82 | 83 | * Use thread-specific filenames 84 | ([#10](https://github.com/GjjvdBurg/signal2html/pull/10) thanks to 85 | @ericthegrey) 86 | 87 | ## Version 0.1.5 88 | 89 | * Add dataclasses dependency for python 3.6 90 | 91 | ## Version 0.1.4 92 | 93 | * Replace absolute URLs for attachments with relative ones. 94 | 95 | ## Version 0.1.3 96 | 97 | * Fix fallback for recipient name (thanks to @Otto-AA!) 98 | * Add several mime-types to HTML template 99 | * Bugfix for recipient names and group detection 100 | ([issue #5](https://github.com/GjjvdBurg/signal2html/issues/5)) 101 | 102 | ## Version 0.1.2 103 | 104 | * Add support for more recent database versions 105 | * Move each thread to a separate folder in output directory 106 | * Copy attachments to the thread directory 107 | 108 | ## Version 0.1.1 109 | 110 | * Minor fixes (encoding, unknown quoted authors) 111 | 112 | ## Version 0.1.0 113 | 114 | * Initial release 115 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | gertjanvandenburg@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020-2021, signal2html contributors. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include signal2html *.html 3 | exclude Makefile 4 | exclude .gitignore 5 | exclude make_release.py 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for easier installation and cleanup. 2 | # 3 | # Uses self-documenting macros from here: 4 | # http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 5 | 6 | SHELL := bash 7 | .SHELLFLAGS := -eu -o pipefail -c 8 | MAKEFLAGS += --no-builtin-rules 9 | 10 | PACKAGE=signal2html 11 | DOC_DIR='./docs' 12 | VENV_DIR=/tmp/s2h_venv 13 | 14 | ################# 15 | # Makefile help # 16 | ################# 17 | 18 | .PHONY: help 19 | 20 | .DEFAULT_GOAL := help 21 | 22 | help: 23 | @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) |\ 24 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m\ 25 | %s\n", $$1, $$2}' 26 | 27 | ################ 28 | # Installation # 29 | ################ 30 | 31 | .PHONY: install 32 | 33 | install: ## Install for the current user using the default python command 34 | python setup.py install --user 35 | 36 | ################ 37 | # Distribution # 38 | ################ 39 | 40 | .PHONY: release dist 41 | 42 | release: ## Make a release 43 | python make_release.py 44 | 45 | dist: ## Make Python source distribution 46 | python setup.py sdist bdist_wheel 47 | 48 | ########### 49 | # Testing # 50 | ########### 51 | 52 | .PHONY: test 53 | 54 | test: venv ## Run unit tests in virtual environment 55 | source $(VENV_DIR)/bin/activate && green -a -s 1 -vv ./tests 56 | 57 | test_direct: ## Run unit tests without virtual environment (typically for CI) 58 | pip install .[tests] && python -m unittest discover -v ./tests 59 | 60 | cover: venv 61 | source $(VENV_DIR)/bin/activate && green -a -r -s 1 -vv ./tests 62 | 63 | ####################### 64 | # Virtual environment # 65 | ####################### 66 | 67 | .PHONY: venv 68 | 69 | venv: $(VENV_DIR)/bin/activate ## Create a virtual environment 70 | 71 | $(VENV_DIR)/bin/activate: 72 | test -d $(VENV_DIR) || python -m venv $(VENV_DIR) 73 | source $(VENV_DIR)/bin/activate && pip install -e .[dev] 74 | touch $(VENV_DIR)/bin/activate 75 | 76 | ############ 77 | # Clean up # 78 | ############ 79 | 80 | clean_venv: ## Clean up the virtual environment 81 | rm -rf $(VENV_DIR) 82 | 83 | clean: ## Clean build dist and egg directories left after install 84 | rm -rf ./dist 85 | rm -rf ./build 86 | rm -rf ./$(PACKAGE).egg-info 87 | rm -rf ./cover 88 | rm -rf $(VENV_DIR) 89 | rm -f MANIFEST 90 | rm -f ./*_valgrind.log* 91 | find . -type f -iname '*.pyc' -delete 92 | find . -type d -name '__pycache__' -empty -delete 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # signal2html: Convert Signal backups to pretty HTML 2 | 3 | [![build](https://github.com/GjjvdBurg/signal2html/actions/workflows/build.yml/badge.svg)](https://github.com/GjjvdBurg/signal2html/actions/workflows/build.yml) 4 | [![PyPI version](https://badge.fury.io/py/signal2html.svg)](https://pypi.org/project/signal2html) 5 | [![Downloads](https://pepy.tech/badge/signal2html)](https://pepy.tech/project/signal2html) 6 | 7 | This is a Python script to convert a backup of [Signal](https://signal.org/) 8 | messages to pretty HTML: 9 | 10 |

11 | 12 |

13 | 14 | ***Update March 2023:** This package isn't much maintained at the moment, 15 | please use the `export` functionality of signalbackup-tools directly: 16 | https://github.com/bepaald/signalbackup-tools#export* 17 | 18 | ## Why? 19 | 20 | My phone was getting full and I wanted to preserve my Signal messages in a 21 | nice way. 22 | 23 | ## How? 24 | 25 | 1. Install this package using pip: 26 | ``` 27 | $ pip install signal2html 28 | ``` 29 | 30 | 2. Next, clone and compile 31 | [signalbackup-tools](https://github.com/bepaald/signalbackup-tools) as 32 | follows: 33 | ``` 34 | $ git clone https://github.com/bepaald/signalbackup-tools 35 | $ cd signalbackup-tools 36 | $ bash BUILDSCRIPT.sh 37 | ``` 38 | This should give you a ``signalbackup-tools`` executable script. 39 | 40 | 3. Create an encrypted backup of your Signal messages in the app (Settings -> 41 | Chats and Media -> Create backup), and transfer this to your computer. Make 42 | sure to record the encryption password. 43 | 44 | 4. Unpack your encrypted backup using ``signalbackup-tools`` as follows: 45 | ``` 46 | $ mkdir signal-backup/ 47 | $ signalbackup-tools signal-YYYY-MM-DD-HH-MM-SS.backup --output signal_backup/ 48 | ``` 49 | where you replace ``signal-YYYY-MM-DD-HH-MM-SS.backup`` with the actual 50 | filename of your Signal backup and ```` with the 30-digit encryption 51 | password (without spaces). 52 | 53 | 5. Now, run ``signal2html`` on the backup directory, as follows: 54 | ``` 55 | $ signal2html -i signal_backup/ -o signal_html/ 56 | ``` 57 | This will create a HTML page for each of the message threads in the 58 | ``signal_html`` directory, which you can subsequently open in your browser. 59 | ***Update March 2023:** This package isn't much maintained at the moment, 60 | please use the `export` functionality of signalbackup-tools directly: 61 | https://github.com/bepaald/signalbackup-tools#export* 62 | 63 | ## Notes 64 | 65 | This is a hastily-written script that has only been tested on a few Signal 66 | database versions. I hope it works on other backup versions as well, but if 67 | you encounter any issues please submit a pull request. 68 | 69 | See the LICENSE file for licensing details and copyright. 70 | 71 | Please be aware that Signal messages are encrypted for a reason, and your 72 | contacts may use it specifically because it provides significant privacy. By 73 | exporting and decrypting your messages, you should take responsibility for 74 | maintaining this same level of privacy (for instance by only storing the 75 | plaintext messages on encypted volumes/drives). 76 | 77 | Originally written by [Gertjan van den Burg](https://gertjan.dev). See the 78 | [contributors](https://github.com/GjjvdBurg/signal2html/graphs/contributors) 79 | file for a full list of all contributors. 80 | -------------------------------------------------------------------------------- /make_release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Do-nothing script for making a release 6 | 7 | This idea comes from here: 8 | https://blog.danslimmon.com/2019/07/15/do-nothing-scripting-the-key-to-gradual-automation/ 9 | 10 | Author: Gertjan van den Burg 11 | Date: 2019-07-23 12 | 13 | """ 14 | 15 | import os 16 | import sys 17 | import tempfile 18 | 19 | import colorama 20 | 21 | 22 | def colored(msg, color=None, style=None): 23 | colors = { 24 | "red": colorama.Fore.RED, 25 | "green": colorama.Fore.GREEN, 26 | "cyan": colorama.Fore.CYAN, 27 | "yellow": colorama.Fore.YELLOW, 28 | "magenta": colorama.Fore.MAGENTA, 29 | None: "", 30 | } 31 | styles = { 32 | "bright": colorama.Style.BRIGHT, 33 | "dim": colorama.Style.DIM, 34 | None: "", 35 | } 36 | pre = colors[color] + styles[style] 37 | post = colorama.Style.RESET_ALL 38 | return f"{pre}{msg}{post}" 39 | 40 | 41 | def cprint(msg, color=None, style=None): 42 | print(colored(msg, color=color, style=style)) 43 | 44 | 45 | def wait_for_enter(): 46 | input(colored("\nPress Enter to continue", style="dim")) 47 | print() 48 | 49 | 50 | def get_package_name(): 51 | with open("./setup.py", "r") as fp: 52 | nameline = next( 53 | (l.strip() for l in fp if l.startswith("NAME = ")), None 54 | ) 55 | return nameline.split("=")[-1].strip().strip('"') 56 | 57 | 58 | def get_package_version(pkgname): 59 | ctx = {} 60 | with open(f"{pkgname.lower()}/__version__.py", "r") as fp: 61 | exec(fp.read(), ctx) 62 | return ctx["__version__"] 63 | 64 | 65 | class Step: 66 | def pre(self, context): 67 | pass 68 | 69 | def post(self, context): 70 | wait_for_enter() 71 | 72 | def run(self, context): 73 | try: 74 | self.pre(context) 75 | self.action(context) 76 | self.post(context) 77 | except KeyboardInterrupt: 78 | cprint("\nInterrupted.", color="red") 79 | raise SystemExit(1) 80 | 81 | def instruct(self, msg): 82 | cprint(msg, color="green") 83 | 84 | def print_run(self, msg): 85 | cprint("Run:", color="cyan", style="bright") 86 | self.print_cmd(msg) 87 | 88 | def print_cmd(self, msg): 89 | cprint("\t" + msg, color="cyan", style="bright") 90 | 91 | def do_cmd(self, cmd): 92 | cprint(f"Going to run: {cmd}", color="magenta", style="bright") 93 | wait_for_enter() 94 | os.system(cmd) 95 | 96 | 97 | class GitToMaster(Step): 98 | def action(self, context): 99 | self.instruct("Make sure you're on master and changes are merged in") 100 | self.print_run("git checkout master") 101 | 102 | 103 | class UpdateChangelog(Step): 104 | def action(self, context): 105 | self.instruct(f"Update change log for version {context['version']}") 106 | self.print_run("vi CHANGELOG.md") 107 | 108 | 109 | class UpdateReadme(Step): 110 | def action(self, context): 111 | self.instruct(f"Update readme if necessary") 112 | self.print_run("vi README.md") 113 | 114 | 115 | class RunTests(Step): 116 | def action(self, context): 117 | self.instruct("Run the unit tests") 118 | self.print_run("make test") 119 | 120 | 121 | class BumpVersionPackage(Step): 122 | def action(self, context): 123 | self.instruct(f"Update __version__.py with new version") 124 | self.do_cmd(f"vi {context['pkgname']}/__version__.py") 125 | 126 | def post(self, context): 127 | wait_for_enter() 128 | context["version"] = self._get_version(context) 129 | 130 | def _get_version(self, context): 131 | # Get the version from the version file 132 | return get_package_version(context["pkgname"]) 133 | 134 | 135 | class MakeClean(Step): 136 | def action(self, context): 137 | self.do_cmd("make clean") 138 | 139 | 140 | class MakeDocs(Step): 141 | def action(self, context): 142 | self.do_cmd("make docs") 143 | 144 | 145 | class MakeDist(Step): 146 | def action(self, context): 147 | self.do_cmd("make dist") 148 | 149 | 150 | class PushToTestPyPI(Step): 151 | def action(self, context): 152 | self.do_cmd( 153 | "twine upload --repository-url https://test.pypi.org/legacy/ dist/*" 154 | ) 155 | 156 | 157 | class InstallFromTestPyPI(Step): 158 | def action(self, context): 159 | tmpvenv = tempfile.mkdtemp(prefix="s2h_venv_") 160 | self.do_cmd( 161 | f"python -m venv {tmpvenv} && source {tmpvenv}/bin/activate && " 162 | "pip install --no-cache-dir --index-url " 163 | "https://test.pypi.org/simple/ " 164 | "--extra-index-url https://pypi.org/simple " 165 | f"{context['pkgname']}=={context['version']}" 166 | ) 167 | context["tmpvenv"] = tmpvenv 168 | 169 | 170 | class TestPackage(Step): 171 | def action(self, context): 172 | self.instruct( 173 | f"Ensure that the following command gives version {context['version']}" 174 | ) 175 | self.do_cmd( 176 | f"source {context['tmpvenv']}/bin/activate && signal2html -V" 177 | ) 178 | 179 | 180 | class RemoveVenv(Step): 181 | def action(self, context): 182 | self.do_cmd(f"rm -rf {context['tmpvenv']}") 183 | 184 | 185 | class GitTagVersion(Step): 186 | def action(self, context): 187 | self.do_cmd(f"git tag v{context['version']}") 188 | 189 | 190 | class GitAdd(Step): 191 | def action(self, context): 192 | self.instruct("Add everything to git and commit") 193 | self.print_run("git gui") 194 | 195 | 196 | class PushToPyPI(Step): 197 | def action(self, context): 198 | self.do_cmd("twine upload dist/*") 199 | 200 | 201 | class PushToGitHub(Step): 202 | def action(self, context): 203 | self.do_cmd("git push -u --tags origin master") 204 | 205 | 206 | class WaitForTravis(Step): 207 | def action(self, context): 208 | self.instruct( 209 | "Wait for Travis to complete and verify that its successful" 210 | ) 211 | 212 | 213 | class WaitForAppVeyor(Step): 214 | def action(self, context): 215 | self.instruct( 216 | "Wait for AppVeyor to complete and verify that its successful" 217 | ) 218 | 219 | 220 | class WaitForRTD(Step): 221 | def action(self, context): 222 | self.instruct( 223 | "Wait for ReadTheDocs to complete and verify that its successful" 224 | ) 225 | 226 | 227 | def main(target=None): 228 | colorama.init() 229 | procedure = [ 230 | ("gittomaster", GitToMaster()), 231 | ("gitadd1", GitAdd()), 232 | ("push1", PushToGitHub()), 233 | ("bumpversion", BumpVersionPackage()), 234 | ("changelog", UpdateChangelog()), 235 | ("readme", UpdateReadme()), 236 | ("clean", MakeClean()), 237 | # ("tests", RunTests()), 238 | ("dist", MakeDist()), 239 | ("testpypi", PushToTestPyPI()), 240 | ("install", InstallFromTestPyPI()), 241 | ("testpkg", TestPackage()), 242 | ("remove_venv", RemoveVenv()), 243 | ("gitadd2", GitAdd()), 244 | ("pypi", PushToPyPI()), 245 | ("tag", GitTagVersion()), 246 | ("push2", PushToGitHub()), 247 | ] 248 | context = {} 249 | context["pkgname"] = get_package_name() 250 | context["version"] = get_package_version(context["pkgname"]) 251 | skip = True if target else False 252 | for name, step in procedure: 253 | if not name == target and skip: 254 | continue 255 | skip = False 256 | step.run(context) 257 | cprint("\nDone!", color="yellow", style="bright") 258 | 259 | 260 | if __name__ == "__main__": 261 | target = sys.argv[1] if len(sys.argv) > 1 else None 262 | main(target=target) 263 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length=79 3 | 4 | [tool.isort] 5 | profile="black" 6 | sections = ['FUTURE', 'STDLIB', 'TYPING', 'THIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER' ] 7 | known_typing = ['typing'] 8 | force_single_line=true 9 | lines_between_types=1 10 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GjjvdBurg/signal2html/24f9519af6d447207ec8a98ec35633dcc5c09c09/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | import io 6 | import os 7 | 8 | from setuptools import find_packages 9 | from setuptools import setup 10 | 11 | # Package meta-data. 12 | NAME = "signal2html" 13 | DESCRIPTION = "Convert your Signal backup to pretty HTML" 14 | URL = "https://github.com/GjjvdBurg/signal2html" 15 | EMAIL = "gertjanvandenburg@gmail.com" 16 | AUTHOR = "Gertjan van den Burg" 17 | REQUIRES_PYTHON = ">=3.6.0" 18 | VERSION = None 19 | 20 | # What packages are required for this module to be executed? 21 | REQUIRED = [ 22 | "emoji>=2.0", 23 | "jinja2", 24 | 'dataclasses;python_version=="3.6"', 25 | "pure-protobuf", 26 | "linkify-it-py", 27 | "filetype" 28 | ] 29 | 30 | docs_require = [] 31 | test_require = [] 32 | dev_require = ["green", "black", "isort"] 33 | 34 | # What packages are optional? 35 | EXTRAS = { 36 | "docs": docs_require, 37 | "tests": test_require, 38 | "dev": docs_require + test_require + dev_require, 39 | } 40 | 41 | # The rest you shouldn't have to touch too much :) 42 | # ------------------------------------------------ 43 | # Except, perhaps the License and Trove Classifiers! 44 | # If you do change the License, remember to change the Trove Classifier for that! 45 | 46 | here = os.path.abspath(os.path.dirname(__file__)) 47 | 48 | # Import the README and use it as the long-description. 49 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 50 | try: 51 | with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f: 52 | long_description = "\n" + f.read() 53 | except FileNotFoundError: 54 | long_description = DESCRIPTION 55 | 56 | # Load the package's __version__.py module as a dictionary. 57 | about = {} 58 | if not VERSION: 59 | project_slug = NAME.lower().replace("-", "_").replace(" ", "_") 60 | with open(os.path.join(here, project_slug, "__version__.py")) as f: 61 | exec(f.read(), about) 62 | else: 63 | about["__version__"] = VERSION 64 | 65 | 66 | # Where the magic happens: 67 | setup( 68 | name=NAME, 69 | version=about["__version__"], 70 | description=DESCRIPTION, 71 | long_description=long_description, 72 | long_description_content_type="text/markdown", 73 | author=AUTHOR, 74 | author_email=EMAIL, 75 | python_requires=REQUIRES_PYTHON, 76 | url=URL, 77 | packages=find_packages( 78 | exclude=["tests", "*.tests", "*.tests.*", "tests.*"] 79 | ), 80 | entry_points={ 81 | "console_scripts": ["signal2html=signal2html.__main__:main"], 82 | }, 83 | install_requires=REQUIRED, 84 | extras_require=EXTRAS, 85 | include_package_data=True, 86 | license="MIT", 87 | classifiers=[ 88 | # Trove classifiers 89 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 90 | "License :: OSI Approved :: MIT License", 91 | "Programming Language :: Python", 92 | "Programming Language :: Python :: 3", 93 | "Programming Language :: Python :: 3.6", 94 | "Programming Language :: Python :: Implementation :: CPython", 95 | "Programming Language :: Python :: Implementation :: PyPy", 96 | ], 97 | ) 98 | -------------------------------------------------------------------------------- /signal2html/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .__version__ import __version__ 4 | from .core import process_backup 5 | -------------------------------------------------------------------------------- /signal2html/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Command line interface 4 | 5 | License: See LICENSE file. 6 | """ 7 | 8 | 9 | def main(): 10 | import logging 11 | import sys 12 | 13 | from .ui import main 14 | 15 | logging.basicConfig( 16 | datefmt="%Y-%m-%d %H:%M:%S", 17 | format="%(asctime)s | %(levelname)s - %(message)s", 18 | level=logging.INFO, 19 | ) 20 | sys.exit(main()) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /signal2html/__version__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | VERSION = (0, 2, 11) 4 | 5 | __version__ = ".".join(map(str, VERSION)) 6 | -------------------------------------------------------------------------------- /signal2html/addressbook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Addressbook functionality 4 | 5 | License: See LICENSE file. 6 | 7 | """ 8 | 9 | import abc 10 | import logging 11 | 12 | from .html_colors import get_random_color 13 | from .models import Recipient 14 | 15 | 16 | class Addressbook(metaclass=abc.ABCMeta): 17 | """Abstract class that store contacts and groups. 18 | 19 | Note: subclasses must implement at a minimum: 20 | 21 | - `_load_recipients()` to load all recipients 22 | - `get_recipient_by_address()` to return a specific recipient""" 23 | 24 | def __init__(self, db): 25 | """Initializes the addressbook and load all known recipients.""" 26 | self.logger = logging.getLogger(__name__) 27 | self.db = db 28 | self.rid_to_recipient: dict[str, Recipient] = {} 29 | self.phone_to_rid: dict[str, str] = {} 30 | self.uuid_to_rid: dict[str, str] = {} 31 | self.groups: dict[int, str] = {} 32 | 33 | self._load_groups() 34 | self._load_recipients() # Must be implemented by subclass 35 | self.next_rid = 10000 36 | 37 | @abc.abstractmethod 38 | def _load_recipients(): 39 | """Load all recipients in the recipient_preferences table.""" 40 | 41 | def get_group_title(self, group_id: str) -> str: 42 | """Retrieves the title of a group given the group_id (long 43 | hexadecimal-based identifier).""" 44 | return self.groups.get(group_id) 45 | 46 | @abc.abstractmethod 47 | def get_recipient_by_address(self, address: str) -> Recipient: 48 | """Returns a Recipient object that matches the address provided. 49 | 50 | The address is the kind of information found in the address field 51 | of the mms/sms message tables, but also in recipient_ids of the 52 | thread table. 53 | 54 | If an address is provided that does not exist in the addressbook, 55 | it is created on the spot.""" 56 | 57 | def get_recipient_by_phone(self, phone: str) -> Recipient: 58 | """Returns a Recipient object that matches the phone number provided.""" 59 | rid = self.phone_to_rid.get(phone) 60 | return self.rid_to_recipient.get(rid) 61 | 62 | def get_recipient_by_uuid(self, uuid: str) -> Recipient: 63 | """Returns a Recipient object that matches the UUID provided.""" 64 | rid = self.uuid_to_rid.get(uuid) 65 | return self.rid_to_recipient.get(rid) 66 | 67 | def _add_recipient(self, recipient_id, uuid, name, color, isgroup, phone): 68 | """Adds a recipient to the internal data structures.""" 69 | recipient = Recipient( 70 | recipient_id, 71 | name=name, 72 | color=color, 73 | isgroup=isgroup, 74 | phone=phone, 75 | uuid=uuid, 76 | ) 77 | 78 | self.rid_to_recipient[str(recipient_id)] = recipient 79 | self.logger.debug( 80 | f"Adding recipient {str(recipient_id)} and phone {phone}" 81 | ) 82 | if phone: 83 | self.phone_to_rid[str(phone)] = str(recipient_id) 84 | if uuid: 85 | self.uuid_to_rid[uuid] = str(recipient_id) 86 | 87 | return recipient 88 | 89 | def _get_friendly_name_for_group(self, address: str): 90 | """Creates a readable group name, either the title or a name derived from the group id.""" 91 | name = self.get_group_title(address) 92 | if not name: 93 | gid = self._get_group_id(address) 94 | if gid: 95 | return f"Group {gid}" 96 | else: 97 | return "" 98 | 99 | def _get_group_id(self, group_id: str) -> str: 100 | """Gets the integer ID of a group from the Signal database.""" 101 | qry = self.db.execute( 102 | "SELECT group_id, _id FROM groups WHERE group_id LIKE ?", 103 | (f"{group_id}",), 104 | ) 105 | qry_res = qry.fetchone() 106 | if qry_res: 107 | return str(qry_res[1]) 108 | 109 | def _get_new_rid(self) -> str: 110 | """Creates a new recipient ID for recipients not in the initial 111 | addressbook.""" 112 | while self.rid_to_recipient.get(str(self.next_rid)): 113 | self.next_rid += 1 114 | 115 | return str(self.next_rid) 116 | 117 | def _get_unique_group_id(self, group_id: str) -> str: 118 | """Given a group ID, returns a unique identifier for the group. 119 | 120 | Group IDs are currently comprised of a type, followed by '!', followed 121 | by an hexadecimal identifier. 122 | 123 | NOTE: This method currently returns the group id itself, but might be 124 | used to merge groups that share the same hexadecimal identifier.""" 125 | return group_id 126 | 127 | def _load_groups(self): 128 | """Loads all group names (a.k.a. titles).""" 129 | qry = self.db.execute("SELECT group_id, title FROM groups") 130 | qry_res = qry.fetchall() 131 | for group_id, title in qry_res: 132 | self.groups[self._get_unique_group_id(group_id)] = title 133 | 134 | 135 | class AddressbookV1(Addressbook): 136 | def get_recipient_by_address(self, address: str) -> Recipient: 137 | """In this database version, all addresses are directly phone numbers 138 | or group_id's and creating them might happen if no preferences were 139 | stored for the particular address.""" 140 | 141 | isgroup = self._isgroup(address) 142 | if isgroup: 143 | phone = self._get_unique_group_id(address) 144 | else: 145 | phone = address 146 | 147 | rid = self.phone_to_rid.get(phone) 148 | recipient = self.rid_to_recipient.get(rid) 149 | 150 | if recipient is None: 151 | # Create on the spot 152 | newrid = self._get_new_rid() 153 | if isgroup: 154 | friendly_name = self._get_friendly_name_for_group(phone) 155 | if friendly_name: 156 | self.logger.info( 157 | f"Group '{phone}' not in addressbook, adding it as '{friendly_name}'." 158 | ) 159 | else: 160 | self.logger.warn( 161 | f"Group '{phone}' not in addressbook, adding it with new ID {newrid}." 162 | ) 163 | return self._add_recipient( 164 | newrid, "", friendly_name, get_random_color(), True, phone 165 | ) 166 | else: 167 | self.logger.info( 168 | f"Recipient with phone '{address}' not in addressbook, adding it." 169 | ) 170 | return self._add_recipient( 171 | newrid, "", address, get_random_color(), False, phone 172 | ) 173 | else: 174 | return recipient 175 | 176 | def _isgroup(self, address: str) -> bool: 177 | """Decides whether an address refers to a group.""" 178 | return address.startswith( 179 | "__textsecure_group__" 180 | ) or address.startswith("__signal_mms_group__") 181 | 182 | def _load_recipients(self): 183 | """Load all recipients in the recipient_preferences table. 184 | 185 | In this version of the database, it is normal for recipients in other 186 | tables not to be found in this table.""" 187 | qry = self.db.execute( 188 | "SELECT _id, recipient_ids, system_display_name, color, signal_profile_name " 189 | "FROM recipient_preferences " 190 | ) 191 | qry_res = qry.fetchall() 192 | 193 | for ( 194 | recipient_id, 195 | phone, 196 | system_display_name, 197 | color, 198 | profile_name, 199 | ) in qry_res: 200 | isgroup = self._isgroup(phone) 201 | if isgroup: 202 | phone = self._get_unique_group_id(phone) 203 | name = self.get_group_title(phone) 204 | if name is None: 205 | name = self._get_friendly_name_for_group(phone) 206 | self.logger.warn( 207 | f"Group for recipient {recipient_id} will be named '{name}'." 208 | ) 209 | else: 210 | name = system_display_name or profile_name or phone or "" 211 | 212 | if color is None: 213 | color = get_random_color() 214 | 215 | self._add_recipient(recipient_id, "", name, color, isgroup, phone) 216 | 217 | 218 | class AddressbookV2(Addressbook): 219 | def get_recipient_by_address(self, address: str) -> Recipient: 220 | """In this database version, all addresses are recipient_id's and 221 | creating them here is not expected to happen.""" 222 | 223 | rid = str(address) 224 | recipient = self.rid_to_recipient.get(rid) 225 | 226 | if recipient is None: 227 | # Create on the spot, but not expected to happen 228 | self.logger.warn( 229 | f"Recipient with rid {address} not in addressbook, adding it." 230 | ) 231 | return self._add_recipient( 232 | rid, "", "", get_random_color(), False, "" 233 | ) 234 | else: 235 | return recipient 236 | 237 | def _isgroup(self, group_id) -> bool: 238 | """Decides whether a group_id refers to a group.""" 239 | return group_id is not None 240 | 241 | def _load_recipients(self): 242 | """Load all recipients in the recipient table. 243 | 244 | In this version of the database, all recipients references should be 245 | found in this table.""" 246 | qry = self.db.execute( 247 | "SELECT _id, group_id, uuid, " 248 | "phone, " 249 | "system_display_name, " 250 | "profile_joined_name, " 251 | "color " 252 | "FROM recipient " 253 | ) 254 | qry_res = qry.fetchall() 255 | for ( 256 | recipient_id, 257 | group_id, 258 | uuid, 259 | phone, 260 | system_display_name, 261 | profile_joined_name, 262 | color, 263 | ) in qry_res: 264 | isgroup = self._isgroup(group_id) 265 | if isgroup: 266 | name = self.get_group_title(group_id) 267 | if name is None: 268 | name = self._get_friendly_name_for_group(group_id) 269 | if name: 270 | self.logger.info( 271 | f"Group for recipient {recipient_id} is '{group_id}' and will be called by group id using name '{name}'." 272 | ) 273 | else: 274 | self.logger.warn( 275 | f"Group for recipient {recipient_id} is '{group_id}' which does not exist." 276 | ) 277 | else: 278 | name = ( 279 | system_display_name or profile_joined_name or phone or "" 280 | ) 281 | 282 | if color is None: 283 | color = get_random_color() 284 | 285 | self._add_recipient( 286 | recipient_id, uuid, name, color, isgroup, phone 287 | ) 288 | 289 | 290 | def make_addressbook(db, versioninfo) -> Addressbook: 291 | """Factory function for Addressbook. 292 | 293 | The returned implementation depends on the structure of the Signal database. 294 | """ 295 | if versioninfo.is_addressbook_using_rids(): 296 | return AddressbookV2(db) 297 | return AddressbookV1(db) 298 | -------------------------------------------------------------------------------- /signal2html/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Core functionality 4 | 5 | License: See LICENSE file. 6 | 7 | """ 8 | 9 | import base64 10 | import binascii 11 | import datetime as dt 12 | import logging 13 | import os 14 | import shutil 15 | import sqlite3 16 | import uuid 17 | import filetype 18 | 19 | from pathlib import Path 20 | 21 | from typing import List 22 | from typing import Tuple 23 | 24 | from .__version__ import __version__ 25 | from .addressbook import Addressbook 26 | from .addressbook import make_addressbook 27 | from .dbproto import StructuredGroupCall 28 | from .dbproto import StructuredGroupDataV1 29 | from .dbproto import StructuredGroupDataV2 30 | from .dbproto import StructuredMemberRole 31 | from .dbproto import StructuredMentions 32 | from .dbproto import StructuredReactions 33 | from .exceptions import DatabaseEmptyError 34 | from .exceptions import DatabaseNotFoundError 35 | from .exceptions import DatabaseVersionNotFoundError 36 | from .html import dump_thread 37 | from .models import Attachment 38 | from .models import GroupCallData 39 | from .models import GroupUpdateData 40 | from .models import MemberInfo 41 | from .models import Mention 42 | from .models import MMSMessageRecord 43 | from .models import Quote 44 | from .models import Reaction 45 | from .models import Recipient 46 | from .models import SMSMessageRecord 47 | from .models import Thread 48 | from .types import is_group_call 49 | from .types import is_group_ctrl 50 | from .types import is_group_v2_data 51 | from .versioninfo import VersionInfo 52 | 53 | logger = logging.getLogger(__name__) 54 | 55 | 56 | def check_backup(backup_dir: Path) -> Tuple[Path, VersionInfo]: 57 | """Check that we have the necessary files and return VersionInfo""" 58 | db_file = backup_dir / "database.sqlite" 59 | if not db_file.exists(): 60 | raise DatabaseNotFoundError(db_file) 61 | db_version_file = backup_dir / "DatabaseVersion.sbf" 62 | if not db_version_file.exists(): 63 | raise DatabaseVersionNotFoundError(db_version_file) 64 | with open(db_version_file, "r") as fp: 65 | version_str = fp.read() 66 | 67 | version = version_str.split(":")[-1].strip() 68 | versioninfo = VersionInfo(version) 69 | 70 | if not versioninfo.is_tested_version(): 71 | logger.warn("This database version is untested, please report errors.") 72 | return db_file, versioninfo 73 | 74 | 75 | def get_color(db, recipient_id): 76 | """Extract recipient color from the database""" 77 | query = db.execute( 78 | "SELECT color FROM recipient_preferences WHERE recipient_ids=?", 79 | (recipient_id,), 80 | ) 81 | color = query.fetchone()[0] 82 | return color 83 | 84 | 85 | def get_sms_records(db, thread, addressbook): 86 | """Collect all the SMS records for a given thread""" 87 | sms_records = [] 88 | sms_qry = db.execute( 89 | "SELECT _id, address, date, date_sent, body, type, " 90 | "delivery_receipt_count, read_receipt_count " 91 | "FROM sms WHERE thread_id=?", 92 | (thread._id,), 93 | ) 94 | qry_res = sms_qry.fetchall() 95 | for ( 96 | _id, 97 | address, 98 | date, 99 | date_sent, 100 | body, 101 | _type, 102 | delivery_receipt_count, 103 | read_receipt_count, 104 | ) in qry_res: 105 | data = get_data_from_body(_type, body, addressbook, _id) 106 | sms_auth = addressbook.get_recipient_by_address(str(address)) 107 | sms = SMSMessageRecord( 108 | _id=_id, 109 | data=data, 110 | delivery_receipt_count=delivery_receipt_count, 111 | read_receipt_count=read_receipt_count, 112 | addressRecipient=sms_auth, 113 | recipient=thread.recipient, 114 | dateSent=date_sent, 115 | dateReceived=date, 116 | threadId=thread._id, 117 | body=body, 118 | _type=_type, 119 | ) 120 | sms_records.append(sms) 121 | return sms_records 122 | 123 | 124 | def get_attachment_filename(_id, unique_id, backup_dir, thread_dir): 125 | """Get the absolute path of an attachment, warn if it doesn't exist""" 126 | fname = f"Attachment_{_id}_{unique_id}.bin" 127 | source = os.path.abspath(os.path.join(backup_dir, fname)) 128 | if not os.path.exists(source): 129 | logger.warn( 130 | f"Couldn't find attachment '{source}'. " 131 | "Maybe it was deleted or never downloaded?" 132 | ) 133 | return None 134 | 135 | filetype_kind = filetype.guess(source) 136 | if filetype_kind is None: 137 | new_fname = fname 138 | else: 139 | extension = filetype_kind.extension 140 | new_fname =f"Attachment_{_id}_{unique_id}.{extension}" 141 | 142 | # Copying here is a bit of a side-effect 143 | target_dir = os.path.abspath(os.path.join(thread_dir, "attachments")) 144 | os.makedirs(target_dir, exist_ok=True) 145 | target = os.path.join(target_dir, new_fname) 146 | shutil.copy(source, target) 147 | url = "/".join([".", "attachments", new_fname]) 148 | return url 149 | 150 | 151 | def add_mms_attachments(db, mms, backup_dir, thread_dir): 152 | """Add all attachment objects to MMS message""" 153 | qry = db.execute( 154 | "SELECT _id, ct, unique_id, voice_note, width, height, quote " 155 | "FROM part WHERE mid=?", 156 | (mms._id,), 157 | ) 158 | for _id, ct, unique_id, voice_note, width, height, quote in qry: 159 | a = Attachment( 160 | contentType=ct, 161 | unique_id=unique_id, 162 | fileName=get_attachment_filename( 163 | _id, unique_id, backup_dir, thread_dir 164 | ), 165 | voiceNote=voice_note, 166 | width=width, 167 | height=height, 168 | quote=quote, 169 | ) 170 | mms.attachments.append(a) 171 | 172 | 173 | def decode_body(body): 174 | """Decode a base64-encoded message body.""" 175 | try: 176 | return base64.b64decode(body) 177 | except (TypeError, ValueError, binascii.Error) as e: 178 | logger.warn(f"Failed to decode body for message '{body}': {str(e)}") 179 | return None 180 | 181 | 182 | def get_group_call_data(rawbody, addressbook, mid): 183 | """Get the data for a group call.""" 184 | 185 | if not rawbody: 186 | return None 187 | 188 | try: 189 | structured_call = StructuredGroupCall.loads(rawbody) 190 | except (ValueError, IndexError, TypeError) as e: 191 | logger.warn( 192 | f"Failed to load group call data for message {mid}:\n" 193 | f"Error message: {str(e)}" 194 | ) 195 | return None 196 | 197 | timestamp = dt.datetime.fromtimestamp(structured_call.when // 1000) 198 | timestamp = timestamp.replace( 199 | microsecond=(structured_call.when % 1000) * 1000 200 | ) 201 | recipient = addressbook.get_recipient_by_uuid(structured_call.by) 202 | if recipient: 203 | initiator = recipient.name 204 | 205 | group_call_data = GroupCallData( 206 | initiator=initiator, 207 | timestamp=timestamp, 208 | ) 209 | 210 | return group_call_data 211 | 212 | 213 | def get_group_update_data_v1(rawbody, addressbook, mid): 214 | """Get the data for a Group V1 update. 215 | 216 | There are two lists of members: 217 | - One list by structures with telephone numbers and/or UUIDs 218 | - Another list by telephone numbers 219 | 220 | Some groups use the first (sometimes without filling the UUID field), 221 | some use the second, and some both. 222 | 223 | Merge both lists and indicate for each member if they were found by phone 224 | or by UUID (preferable).""" 225 | 226 | if not rawbody: 227 | return None 228 | 229 | try: 230 | structured_group_data = StructuredGroupDataV1.loads(rawbody) 231 | except (ValueError, IndexError, TypeError) as e: 232 | logger.warn( 233 | f"Failed to load group update data (v1) for message {mid}:\n" 234 | f"Error message: {str(e)}" 235 | ) 236 | return None 237 | 238 | members = dict() 239 | for member in structured_group_data.members: 240 | name = None 241 | match_from_phone = False 242 | if member.uuid: 243 | name = addressbook.get_recipient_by_uuid(member.uuid).name 244 | elif member.phone: 245 | name = addressbook.get_recipient_by_phone(member.phone).name 246 | match_from_phone = True 247 | 248 | member_info = MemberInfo( 249 | name=name, 250 | phone=member.phone, 251 | match_from_phone=match_from_phone, 252 | admin=False, 253 | ) 254 | members[member.phone] = member_info 255 | 256 | for phone_member in structured_group_data.phone_members: 257 | if not members.get(phone_member): 258 | name = addressbook.get_recipient_by_phone(phone_member).name 259 | member_info = MemberInfo( 260 | name=name, 261 | phone=phone_member, 262 | match_from_phone=True, 263 | admin=False, 264 | ) 265 | members[phone_member] = member_info 266 | 267 | members_list = list() 268 | for key, value in members.items(): 269 | members_list.append(value) 270 | 271 | group_update_data = GroupUpdateData( 272 | group_name=structured_group_data.group_name, 273 | change_by=None, 274 | members=members_list, 275 | ) 276 | return group_update_data 277 | 278 | 279 | def get_member_by_raw_uuid(raw_uuid: bytes, what: str, addressbook, mid: str): 280 | """Find a recipient from a binary UUID. Output their name from the 281 | addressbook or the textual UUID if not found.""" 282 | 283 | try: 284 | text_uuid = str(uuid.UUID(bytes=raw_uuid)) 285 | except ValueError as e: 286 | logger.warn(f"Failed to parse {what} UUID for message {mid}: {str(e)}") 287 | return None 288 | 289 | recipient = addressbook.get_recipient_by_uuid(text_uuid) 290 | member_name = recipient.name if recipient else text_uuid 291 | return member_name 292 | 293 | 294 | def get_group_update_data_v2(rawbody, addressbook, mid): 295 | """Get the data for a Group V2 update. 296 | 297 | Group V2 updates use UUIDs exclusively to identify members. The update 298 | messages contain the old state, the new state, and a description of the 299 | changes. Parse the change description to print out: 300 | - The person who made the change (editor) 301 | - A new group name 302 | - Added (new) members 303 | - Deleted members 304 | 305 | Also parse the new state to print out the resulting list of members.""" 306 | 307 | if not rawbody: 308 | return None 309 | 310 | try: 311 | structured_group_data = StructuredGroupDataV2.loads(rawbody) 312 | except (ValueError, IndexError, TypeError) as e: 313 | logger.warn( 314 | f"Failed to load group update data (v2) for message {mid}:\n" 315 | f"Error message: {str(e)}" 316 | ) 317 | return None 318 | 319 | change = structured_group_data.change 320 | deleted_members = [] 321 | new_members = [] 322 | members = [] 323 | change_by = None 324 | editor = None 325 | if (not change is None) and (not change.by is None): 326 | editor = get_member_by_raw_uuid( 327 | change.by, "update editor", addressbook, mid 328 | ) 329 | if editor: 330 | change_by = MemberInfo( 331 | name=editor, 332 | phone=None, 333 | match_from_phone=False, 334 | admin=False, 335 | ) 336 | 337 | if (not change is None) and (not change.new_members is None): 338 | for member in change.new_members: 339 | name = ( 340 | get_member_by_raw_uuid( 341 | member.uuid, "new member", addressbook, mid 342 | ) 343 | or "Unknown" 344 | ) 345 | admin = member.role == StructuredMemberRole.MEMBER_ROLE_ADMIN 346 | new_members.append( 347 | MemberInfo( 348 | name=name, phone=None, match_from_phone=False, admin=admin 349 | ) 350 | ) 351 | 352 | if (not change is None) and (not change.deleted_members is None): 353 | for member in change.deleted_members: 354 | name = ( 355 | get_member_by_raw_uuid( 356 | member, "deleted member", addressbook, mid 357 | ) 358 | or "Unknown" 359 | ) 360 | deleted_members.append( 361 | MemberInfo( 362 | name=name, phone=None, match_from_phone=False, admin=False 363 | ) 364 | ) 365 | 366 | state = structured_group_data.state 367 | for member in state.members: 368 | name = ( 369 | get_member_by_raw_uuid(member.uuid, "member", addressbook, mid) 370 | or "Unknown" 371 | ) 372 | admin = member.role == StructuredMemberRole.MEMBER_ROLE_ADMIN 373 | members.append( 374 | MemberInfo( 375 | name=name, phone=None, match_from_phone=False, admin=admin 376 | ) 377 | ) 378 | 379 | if (change is None) or (change.new_title is None): 380 | group_name = None 381 | else: 382 | group_name = change.new_title.value 383 | 384 | group_update_data = GroupUpdateData( 385 | group_name=group_name, 386 | change_by=change_by, 387 | members=members, 388 | new_members=new_members, 389 | deleted_members=deleted_members, 390 | ) 391 | return group_update_data 392 | 393 | 394 | def get_data_from_body(_type, body, addressbook, mid): 395 | """Decode data in the message body and provide a structured representation.""" 396 | data = None 397 | if is_group_call(_type): 398 | data = get_group_call_data(decode_body(body), addressbook, mid) 399 | elif is_group_ctrl(_type): 400 | if is_group_v2_data(_type): 401 | data = get_group_update_data_v2( 402 | decode_body(body), addressbook, mid 403 | ) 404 | else: 405 | data = get_group_update_data_v1( 406 | decode_body(body), addressbook, mid 407 | ) 408 | 409 | return data 410 | 411 | 412 | def get_mms_mentions(encoded_mentions, addressbook, mid): 413 | """Decode mentions encoded in a SQL blob.""" 414 | mentions = {} 415 | if not encoded_mentions: 416 | return mentions 417 | 418 | try: 419 | structured_mentions = StructuredMentions.loads(encoded_mentions) 420 | except (ValueError, IndexError, TypeError) as e: 421 | logger.warn( 422 | f"Failed to load quote mentions for message {mid}:\n" 423 | f"Error message: {str(e)}" 424 | ) 425 | return mentions 426 | 427 | for structured_mention in structured_mentions.mentions: 428 | recipient = addressbook.get_recipient_by_uuid( 429 | structured_mention.who_uuid 430 | ) 431 | name = recipient.name 432 | mention = Mention( 433 | mention_id=-1, name=name, length=structured_mention.length 434 | ) 435 | range_start = ( 436 | 0 if structured_mention.start is None else structured_mention.start 437 | ) 438 | mentions[range_start] = mention 439 | 440 | return mentions 441 | 442 | 443 | def get_mms_reactions(encoded_reactions, addressbook, mid): 444 | """Decode reactions encoded in a SQL blob.""" 445 | reactions = [] 446 | if not encoded_reactions: 447 | return reactions 448 | 449 | try: 450 | structured_reactions = StructuredReactions.loads(encoded_reactions) 451 | except (ValueError, IndexError, TypeError) as e: 452 | logger.warn( 453 | f"Failed to load reactions for message {mid}:\n" 454 | f"Error message: {str(e)}" 455 | ) 456 | return reactions 457 | 458 | for structured_reaction in structured_reactions.reactions: 459 | recipient = addressbook.get_recipient_by_address( 460 | str(structured_reaction.who) 461 | ) 462 | reaction = Reaction( 463 | recipient=recipient, 464 | what=structured_reaction.what, 465 | time_sent=dt.datetime.fromtimestamp( 466 | structured_reaction.time_sent // 1000 467 | ), 468 | time_received=dt.datetime.fromtimestamp( 469 | structured_reaction.time_received // 1000 470 | ), 471 | ) 472 | reaction.time_sent = reaction.time_sent.replace( 473 | microsecond=(structured_reaction.time_sent % 1000) * 1000 474 | ) 475 | reaction.time_received = reaction.time_received.replace( 476 | microsecond=(structured_reaction.time_received % 1000) * 1000 477 | ) 478 | reactions.append(reaction) 479 | 480 | return reactions 481 | 482 | 483 | def get_mms_records( 484 | db, thread, addressbook, backup_dir, thread_dir, versioninfo 485 | ): 486 | """Collect all MMS records for a given thread""" 487 | mms_records = [] 488 | 489 | reaction_expr = versioninfo.get_reactions_query_column() 490 | quote_mentions_expr = versioninfo.get_quote_mentions_query_column() 491 | viewed_receipt_count_expr = versioninfo.get_viewed_receipt_count_column() 492 | 493 | qry = db.execute( 494 | "SELECT _id, address, date, date_received, body, quote_id, " 495 | f"quote_author, quote_body, {quote_mentions_expr}, msg_box, {reaction_expr}, " 496 | f"delivery_receipt_count, read_receipt_count, {viewed_receipt_count_expr} " 497 | "FROM mms WHERE thread_id=?", 498 | (thread._id,), 499 | ) 500 | qry_res = qry.fetchall() 501 | for ( 502 | _id, 503 | address, 504 | date, 505 | date_received, 506 | body, 507 | quote_id, 508 | quote_author, 509 | quote_body, 510 | quote_mentions, 511 | msg_box, 512 | reactions, 513 | delivery_receipt_count, 514 | read_receipt_count, 515 | viewed_receipt_count, 516 | ) in qry_res: 517 | quote = get_mms_quote( 518 | addressbook, 519 | quote_id, 520 | quote_author, 521 | quote_body, 522 | quote_mentions, 523 | _id, 524 | ) 525 | 526 | decoded_reactions = get_mms_reactions(reactions, addressbook, _id) 527 | 528 | data = get_data_from_body(msg_box, body, addressbook, _id) 529 | mms_auth = addressbook.get_recipient_by_address(str(address)) 530 | mms = MMSMessageRecord( 531 | _id=_id, 532 | data=data, 533 | delivery_receipt_count=delivery_receipt_count, 534 | read_receipt_count=read_receipt_count, 535 | addressRecipient=mms_auth, 536 | recipient=thread.recipient, 537 | dateSent=date, 538 | dateReceived=date_received, 539 | threadId=thread._id, 540 | body=body, 541 | quote=quote, 542 | attachments=[], 543 | reactions=decoded_reactions, 544 | _type=msg_box, 545 | viewed_receipt_count=viewed_receipt_count, 546 | ) 547 | mms_records.append(mms) 548 | 549 | for mms in mms_records: 550 | add_mms_attachments(db, mms, backup_dir, thread_dir) 551 | 552 | return mms_records 553 | 554 | 555 | def get_mms_quote( 556 | addressbook, quote_id, quote_author, quote_body, quote_mentions, mid 557 | ): 558 | """Retrieve quote (replied message) from a MMS message.""" 559 | quote = None 560 | if quote_id: 561 | quote_auth = addressbook.get_recipient_by_address(quote_author) 562 | decoded_mentions = get_mms_mentions(quote_mentions, addressbook, mid) 563 | quote = Quote( 564 | _id=quote_id, 565 | author=quote_auth, 566 | text=quote_body, 567 | mentions=decoded_mentions, 568 | ) 569 | return quote 570 | 571 | 572 | def get_mentions(db, addressbook, thread_id, versioninfo): 573 | """Retrieve all mentions in the DB for the requested thread into a dictionary.""" 574 | mentions = {} 575 | 576 | if versioninfo.are_mentions_supported(): 577 | query = db.execute( 578 | "SELECT _id, message_id, recipient_id, range_start, range_length " 579 | "FROM mention WHERE thread_id=?", 580 | (thread_id,), 581 | ) 582 | mentions_data = query.fetchall() 583 | 584 | for ( 585 | _id, 586 | message_id, 587 | recipient_id, 588 | range_start, 589 | range_length, 590 | ) in mentions_data: 591 | name = addressbook.get_recipient_by_address(str(recipient_id)).name 592 | mention = Mention( 593 | mention_id=_id, 594 | name=name, 595 | length=range_length, 596 | ) 597 | if not message_id in mentions.keys(): 598 | mentions[message_id] = {} 599 | mentions[message_id][range_start] = mention 600 | 601 | return mentions 602 | 603 | 604 | def get_members( 605 | db: sqlite3.Cursor, 606 | addressbook: Addressbook, 607 | thread_id: int, 608 | versioninfo: VersionInfo, 609 | ) -> List[Recipient]: 610 | """Retrieve the thread members from the database 611 | 612 | Returns 613 | ------- 614 | members: List[Recipient] 615 | A list of Recipients for each member in the group. 616 | """ 617 | thread_rid_column = versioninfo.get_thread_recipient_id_column() 618 | if versioninfo.is_addressbook_using_rids(): 619 | query = db.execute( 620 | "SELECT r._id, g.members " 621 | "FROM thread t " 622 | "LEFT JOIN recipient r " 623 | f"ON t.{thread_rid_column} = r._id " 624 | "LEFT JOIN groups g " 625 | "ON g.group_id = r.group_id " 626 | "WHERE t._id = :thread_id", 627 | {"thread_id": thread_id}, 628 | ) 629 | query_result = query.fetchall() 630 | recipient_id, thread_members = query_result[0] 631 | else: 632 | query = db.execute( 633 | "SELECT t.recipient_ids, g.members " 634 | "FROM thread t " 635 | "LEFT JOIN groups g " 636 | "ON t.recipient_ids = g.group_id " 637 | "WHERE t._id = :thread_id", 638 | {"thread_id": thread_id}, 639 | ) 640 | query_result = query.fetchall() 641 | recipient_id, thread_members = query_result[0] 642 | 643 | if not thread_members is None: 644 | member_addresses = thread_members.split(",") 645 | members = [] 646 | for address in member_addresses: 647 | recipient = addressbook.get_recipient_by_address(address) 648 | members.append(recipient) 649 | else: 650 | members = [addressbook.get_recipient_by_address(recipient_id)] 651 | return members 652 | 653 | 654 | def populate_thread( 655 | db, thread, addressbook, backup_dir, thread_dir, versioninfo=None 656 | ): 657 | """Populate a thread with all corresponding messages""" 658 | sms_records = get_sms_records(db, thread, addressbook) 659 | mms_records = get_mms_records( 660 | db, 661 | thread, 662 | addressbook, 663 | backup_dir, 664 | thread_dir, 665 | versioninfo, 666 | ) 667 | thread.sms = sms_records 668 | thread.mms = mms_records 669 | thread.mentions = get_mentions(db, addressbook, thread._id, versioninfo) 670 | thread.members = get_members(db, addressbook, thread._id, versioninfo) 671 | 672 | 673 | def process_backup(backup_dir: Path, output_dir: Path): 674 | """Main functionality to convert database into HTML""" 675 | 676 | logger.info(f"This is signal2html version {__version__}") 677 | 678 | # Verify backup and open database 679 | db_file, versioninfo = check_backup(backup_dir) 680 | db_conn = sqlite3.connect(db_file) 681 | db = db_conn.cursor() 682 | 683 | # Check if database is empty 684 | qry = db.execute("SELECT COUNT(*) FROM sqlite_schema") 685 | record = qry.fetchone() 686 | if record == (0,): 687 | raise DatabaseEmptyError() 688 | 689 | # Get and index all contact and group names 690 | addressbook = make_addressbook(db, versioninfo) 691 | 692 | # Start by getting the Threads from the database 693 | recipient_id_expr = versioninfo.get_thread_recipient_id_column() 694 | 695 | query = db.execute(f"SELECT _id, {recipient_id_expr} FROM thread") 696 | threads = query.fetchall() 697 | 698 | # Combine the recipient objects and the thread info into Thread objects 699 | for _id, recipient_id in threads: 700 | recipient = addressbook.get_recipient_by_address(str(recipient_id)) 701 | if recipient is None: 702 | logger.warn(f"No recipient with address {recipient_id}") 703 | 704 | t = Thread(_id=_id, recipient=recipient) 705 | thread_dir = t.get_thread_dir(output_dir, make_dir=False) 706 | populate_thread( 707 | db, t, addressbook, backup_dir, thread_dir, versioninfo=versioninfo 708 | ) 709 | dump_thread(t, output_dir) 710 | 711 | db.close() 712 | -------------------------------------------------------------------------------- /signal2html/dbproto.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Extraction classes for Signal protocol objects. 5 | 6 | These classes are used to extract values in the database encoded as protobuf messages. 7 | 8 | Signal protobuf definitions are defined in these directories: 9 | 10 | https://github.com/signalapp/Signal-Android/tree/master/libsignal/service/src/main/proto 11 | https://github.com/signalapp/Signal-Android/tree/master/app/src/main/proto 12 | 13 | License: See LICENSE file. 14 | """ 15 | 16 | from dataclasses import dataclass 17 | from enum import IntEnum 18 | 19 | from typing import List 20 | from typing import Optional 21 | 22 | from pure_protobuf.dataclasses_ import field 23 | from pure_protobuf.dataclasses_ import message 24 | from pure_protobuf.dataclasses_ import optional_field 25 | from pure_protobuf.types import uint32 26 | from pure_protobuf.types import uint64 27 | 28 | 29 | @message 30 | @dataclass 31 | class StructuredReaction: 32 | what: str = optional_field(1) 33 | who: Optional[uint64] = optional_field(2) 34 | time_sent: Optional[uint64] = optional_field(3) 35 | time_received: Optional[uint64] = optional_field(4) 36 | 37 | 38 | @message 39 | @dataclass 40 | class StructuredReactions: 41 | reactions: List[StructuredReaction] = field(1, default_factory=list) 42 | 43 | 44 | @message 45 | @dataclass 46 | class StructuredMention: 47 | start: uint32 = optional_field(1) 48 | length: uint32 = optional_field(2) 49 | who_uuid: str = optional_field(3) 50 | 51 | 52 | @message 53 | @dataclass 54 | class StructuredMentions: 55 | mentions: List[StructuredMention] = field(1, default_factory=list) 56 | 57 | 58 | @message 59 | @dataclass 60 | class StructuredGroupCall: 61 | by: str = optional_field(2) 62 | when: uint64 = optional_field(3) 63 | 64 | 65 | @message 66 | @dataclass 67 | class StructuredGroupMember: 68 | uuid: str = optional_field(1) 69 | phone: str = optional_field(2) 70 | 71 | 72 | @message 73 | @dataclass 74 | class StructuredGroupDataV1: 75 | group_name: str = optional_field(3) 76 | phone_members: List[str] = field(4, default_factory=list) 77 | members: List[StructuredGroupMember] = field(6, default_factory=list) 78 | 79 | 80 | class StructuredMemberRole(IntEnum): 81 | MEMBER_ROLE_UNKNOWN = 0 82 | MEMBER_ROLE_DEFAULT = 1 83 | MEMBER_ROLE_ADMIN = 2 84 | 85 | 86 | @message 87 | @dataclass 88 | class StructuredDecryptedMember: 89 | uuid: bytes = field(1) 90 | role: StructuredMemberRole = field(2) 91 | 92 | 93 | @message 94 | @dataclass 95 | class StructuredDecryptedString: 96 | value: str = field(1) 97 | 98 | 99 | @message 100 | @dataclass 101 | class StructuredGroupV2Change: 102 | by: bytes = optional_field(1) 103 | new_members: List[StructuredDecryptedMember] = field( 104 | 3, default_factory=list 105 | ) 106 | deleted_members: List[bytes] = field(4, default_factory=list) 107 | new_title: StructuredDecryptedString = optional_field(10) 108 | 109 | 110 | @message 111 | @dataclass 112 | class StructuredGroupV2State: 113 | title: str = optional_field(2) 114 | rev: uint32 = optional_field(6) 115 | members: List[StructuredDecryptedMember] = field(7, default_factory=list) 116 | 117 | 118 | @message 119 | @dataclass 120 | class StructuredGroupDataV2: 121 | change: StructuredGroupV2Change = optional_field(2) 122 | state: StructuredGroupV2State = optional_field(3) 123 | -------------------------------------------------------------------------------- /signal2html/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Various exceptions 4 | 5 | License: See LICENSE file. 6 | 7 | """ 8 | 9 | 10 | class DatabaseNotFoundError(FileNotFoundError): 11 | pass 12 | 13 | 14 | class DatabaseVersionNotFoundError(FileNotFoundError): 15 | pass 16 | 17 | 18 | class DatabaseEmptyError(ValueError): 19 | def __init__(self): 20 | super().__init__( 21 | "Database is empty, something must have gone wrong exporting or " 22 | "unpacking the Signal backup." 23 | ) 24 | -------------------------------------------------------------------------------- /signal2html/html.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Code for writing out the HTML 4 | 5 | License: See LICENSE file. 6 | 7 | """ 8 | 9 | import datetime as dt 10 | import logging 11 | 12 | from types import SimpleNamespace as ns 13 | 14 | from emoji import emoji_list 15 | from jinja2 import Environment 16 | from jinja2 import PackageLoader 17 | from jinja2 import select_autoescape 18 | 19 | from .html_colors import get_color 20 | from .html_colors import list_colors 21 | from .linkify import linkify 22 | from .models import MMSMessageRecord 23 | from .models import Thread 24 | from .types import DisplayType 25 | from .types import get_named_message_type 26 | from .types import is_group_call 27 | from .types import is_group_ctrl 28 | from .types import is_inbox_type 29 | from .types import is_incoming_call 30 | from .types import is_joined_type 31 | from .types import is_key_update 32 | from .types import is_missed_call 33 | from .types import is_outgoing_call 34 | from .types import is_secure 35 | 36 | logger = logging.getLogger(__name__) 37 | 38 | 39 | def is_all_emoji(body): 40 | """Check if a message is non-empty and only contains emoji""" 41 | body = body.replace(" ", "").replace("\ufe0f", "") 42 | return len(emoji_list(body)) == len(body) and len(body) > 0 43 | 44 | 45 | def format_message(body, mentions=None): 46 | """Format message by processing all characters. 47 | 48 | - Wrap emoji in for styling them 49 | - Escape special HTML chars 50 | """ 51 | if body is None: 52 | return None 53 | 54 | if mentions is None: 55 | mentions = {} 56 | 57 | emoji_pos = emoji_list(body) 58 | new_body = "" 59 | emoji_lookup = {p["match_start"]: p["emoji"] for p in emoji_pos} 60 | skip = 0 61 | for i, c in enumerate(body): 62 | if skip > 0: 63 | # Skip additional characters from multi-character emoji 64 | skip = skip - 1 65 | elif i in emoji_lookup: 66 | new_body += "%s" % emoji_lookup[i] 67 | skip = len(emoji_lookup[i]) - 1 68 | elif c == "&": 69 | new_body += "&" 70 | elif c == "<": 71 | new_body += "<" 72 | elif c == ">": 73 | new_body += ">" 74 | elif c == "\ufffc": # Object replacement character 75 | mention = mentions.get(i) 76 | if mention: 77 | new_body += ( 78 | "@%s" 79 | % format_message(mention.name) 80 | ) 81 | skip = ( 82 | mention.length - 1 83 | ) # Not clear in what case this is not 1 84 | else: 85 | new_body += c 86 | else: 87 | new_body += c 88 | 89 | new_body = linkify(new_body) 90 | return new_body 91 | 92 | 93 | def format_member_list(header: str, member_list): 94 | """Return a list of printable group members belonging to a category (e.g. new members).""" 95 | people = ns() 96 | people.header = header 97 | people.members = list() 98 | for member in member_list: 99 | designation = "" 100 | if not member.match_from_phone: 101 | if member.phone: 102 | designation = f"{format_message(member.name)} ({format_message(member.phone)})" 103 | else: 104 | designation = f"{format_message(member.name)}" 105 | else: 106 | if member.name is None or member.name == member.phone: 107 | designation = f"{format_message(member.phone)}" 108 | else: 109 | designation = f"{format_message(member.phone)} ~ {format_message(member.name)}" 110 | 111 | if member.admin: 112 | designation += " (admin)" 113 | 114 | people.members.append(designation) 115 | 116 | return people 117 | 118 | 119 | def format_event_data_group_update(data): 120 | """Return a structure describing a group update event.""" 121 | event_data = ns() 122 | event_data.member_lists = list() 123 | 124 | event_data.header = "Group update" 125 | if data.change_by: 126 | event_data.header += " by " + format_message(data.change_by.name) 127 | 128 | if data.group_name: 129 | event_data.name = data.group_name 130 | 131 | if data.new_members and len(data.new_members) > 0: 132 | member_list = format_member_list("New members:", data.new_members) 133 | event_data.member_lists.append(member_list) 134 | 135 | if data.deleted_members and len(data.deleted_members) > 0: 136 | member_list = format_member_list( 137 | "Deleted members:", data.deleted_members 138 | ) 139 | event_data.member_lists.append(member_list) 140 | 141 | if data.members and len(data.members) > 0: 142 | member_list = format_member_list("Members:", data.members) 143 | event_data.member_lists.append(member_list) 144 | 145 | return event_data 146 | 147 | 148 | def dump_thread(thread: Thread, output_dir: str): 149 | """Write a Thread instance to a HTML page in the output directory""" 150 | 151 | # Combine and sort the messages 152 | messages = thread.mms + thread.sms 153 | messages.sort(key=lambda mr: mr.dateSent) 154 | 155 | # Find the template 156 | env = Environment( 157 | loader=PackageLoader("signal2html", "templates"), 158 | autoescape=select_autoescape(["html", "xml"]), 159 | ) 160 | template = env.get_template("thread.html") 161 | 162 | # Create the message color CSS (depends on individuals) 163 | group_color_css = "" 164 | msg_css = ".msg-sender-%i { /* recipient id: %5s */ background: %s;}\n" 165 | if thread.is_group: 166 | group_recipients = set(m.addressRecipient for m in messages) 167 | sender_idx = {r: k for k, r in enumerate(group_recipients)} 168 | colors_used = [] 169 | group_colors = set(ar.color for ar in sender_idx) 170 | for ar, idx in sender_idx.items(): 171 | if ar.isgroup: 172 | continue 173 | 174 | # ensure colors are unique, even if they're not in Signal 175 | ar_color = ar.color 176 | if ar_color in colors_used: 177 | color = next( 178 | (c for c in list_colors() if not c in group_colors), 179 | None, 180 | ) 181 | ar_color = ar.color if color is None else color 182 | group_color_css += msg_css % ( 183 | idx, 184 | ar.rid, 185 | get_color(ar_color), 186 | ) 187 | colors_used.append(ar.color) 188 | else: 189 | # Retrieve sender info from an incoming message, if any 190 | firstInbox = next( 191 | (m for m in messages if is_inbox_type(m._type)), None 192 | ) 193 | if firstInbox: 194 | clr = firstInbox.addressRecipient.color 195 | clr = "teal" if clr is None else clr 196 | group_color_css += msg_css % ( 197 | 0, 198 | firstInbox.addressRecipient.rid, 199 | get_color(clr), 200 | ) 201 | 202 | # Create a simplified dict for each message 203 | prev_date = None 204 | simple_messages = [] 205 | for msg in messages: 206 | if is_joined_type(msg._type): 207 | continue 208 | 209 | # Add a "date change" message when to mark the date 210 | date_sent = dt.datetime.fromtimestamp(msg.dateSent // 1000) 211 | date_sent = date_sent.replace(microsecond=(msg.dateSent % 1000) * 1000) 212 | if prev_date is None or date_sent.date() != prev_date: 213 | prev_date = date_sent.date() 214 | out = { 215 | "date_msg": True, 216 | "body": date_sent.strftime("%a, %b %d, %Y"), 217 | } 218 | simple_messages.append(out) 219 | 220 | # Handle event messages (calls, group changes) 221 | is_event = False 222 | event_data = None 223 | if is_incoming_call(msg._type): 224 | is_event = True 225 | event_data = format_message(thread.name) 226 | elif is_outgoing_call(msg._type): 227 | is_event = True 228 | elif is_missed_call(msg._type): 229 | is_event = True 230 | elif is_group_call(msg._type): 231 | is_event = True 232 | if msg.data is not None: 233 | if msg.data.initiator: 234 | event_data = format_message(msg.data.initiator) 235 | else: 236 | logger.warn(f"Group call for {msg._id} without data") 237 | elif is_key_update(msg._type): 238 | is_event = True 239 | event_data = format_message(msg.addressRecipient.name) 240 | elif is_group_ctrl(msg._type) and not msg.data is None: 241 | is_event = True 242 | event_data = format_event_data_group_update( 243 | msg.data 244 | ) # "Group update (v2)" 245 | 246 | # Deal with quoted messages 247 | quote = {} 248 | if isinstance(msg, MMSMessageRecord) and msg.quote: 249 | quote_author_id = msg.quote.author.rid 250 | quote_author_name = msg.quote.author.name 251 | if quote_author_id == quote_author_name: 252 | name = "You" 253 | else: 254 | name = quote_author_name 255 | quote = { 256 | "name": name, 257 | "body": format_message(msg.quote.text, msg.quote.mentions), 258 | "attachments": [], 259 | } 260 | 261 | # Clean up message body 262 | body = "" if msg.body is None else msg.body 263 | if isinstance(msg, MMSMessageRecord): 264 | all_emoji = not msg.quote and is_all_emoji(body) 265 | else: 266 | all_emoji = is_all_emoji(body) 267 | 268 | # Skip HTML/mentions clean-up if this is an event (formatting included in event) 269 | if not is_event: 270 | body = format_message(body, thread.mentions.get(msg._id)) 271 | 272 | send_state = str( 273 | DisplayType.from_state( 274 | msg._type, 275 | msg.delivery_receipt_count > 0, 276 | msg.read_receipt_count > 0, 277 | ) 278 | ) 279 | send_state = send_state[ 280 | send_state.index(".") + 1 : 281 | ] # A bit hackish, StrEnum would be better (Python 3.10) 282 | 283 | # Create message dictionary 284 | aR = msg.addressRecipient 285 | out = { 286 | "isAllEmoji": all_emoji, 287 | "isGroup": thread.is_group, 288 | "isCall": is_event, 289 | "type": get_named_message_type(msg._type), 290 | "body": body, 291 | "event_data": event_data if is_event else None, 292 | "date": date_sent, 293 | "attachments": [], 294 | "id": msg._id, 295 | "name": aR.name, 296 | "secure": is_secure(msg._type) or is_event, 297 | "send_state": send_state, 298 | "delivery_receipt_count": msg.delivery_receipt_count, 299 | "read_receipt_count": msg.read_receipt_count, 300 | "sender_idx": sender_idx[aR] if thread.is_group else "0", 301 | "quote": quote, 302 | "reactions": [], 303 | } 304 | 305 | # Add attachments and reactions 306 | if isinstance(msg, MMSMessageRecord): 307 | for a in msg.attachments: 308 | if a.quote: 309 | out["quote"]["attachments"].append(a) 310 | else: 311 | out["attachments"].append(a) 312 | 313 | for r in msg.reactions: 314 | out["reactions"].append( 315 | { 316 | "recipient_id": r.recipient.rid, 317 | "name": r.recipient.name, 318 | "what": r.what, 319 | "time_sent": r.time_sent, 320 | "time_received": r.time_received, 321 | } 322 | ) 323 | 324 | simple_messages.append(out) 325 | 326 | if not simple_messages: 327 | return 328 | 329 | if thread.is_group: 330 | count = len(thread.members) 331 | subtitle = f"{count} member" if count == 1 else f"{count} members" 332 | else: 333 | subtitle = thread.sanephone 334 | 335 | html = template.render( 336 | thread_name=thread.name, 337 | thread_subtitle=subtitle, 338 | messages=simple_messages, 339 | group_color_css=group_color_css, 340 | date_time_format="%b %d, %H:%M", 341 | ) 342 | output_file = thread.get_path(output_dir) 343 | with open(output_file, "w", encoding="utf-8") as fp: 344 | fp.write(html) 345 | -------------------------------------------------------------------------------- /signal2html/html_colors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Colors extracted from Signal source 4 | 5 | License: See LICENSE file. 6 | 7 | """ 8 | 9 | import logging 10 | import random 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | CRIMSON = "#CC163D" 15 | CRIMSON_TINT = "#EDA6AE" 16 | CRIMSON_SHADE = "#8A0F29" 17 | VERMILLION = "#C73800" 18 | VERMILLION_TINT = "#EBA78E" 19 | VERMILLION_SHADE = "#872600" 20 | BURLAP = "#746C53" 21 | BURLAP_TINT = "#C4B997" 22 | BURLAP_SHADE = "#58513C" 23 | FOREST = "#3B7845" 24 | FOREST_TINT = "#8FCC9A" 25 | FOREST_SHADE = "#2B5934" 26 | WINTERGREEN = "#1C8260" 27 | WINTERGREEN_TINT = "#9BCFBD" 28 | WINTERGREEN_SHADE = "#36544A" 29 | TEAL = "#067589" 30 | TEAL_TINT = "#A5CAD5" 31 | TEAL_SHADE = "#055968" 32 | BLUE = "#336BA3" 33 | BLUE_TINT = "#ADC8E1" 34 | BLUE_SHADE = "#285480" 35 | INDIGO = "#5951C8" 36 | INDIGO_TINT = "#C2C1E7" 37 | INDIGO_SHADE = "#4840A0" 38 | VIOLET = "#862CAF" 39 | VIOLET_TINT = "#CDADDC" 40 | VIOLET_SHADE = "#6B248A" 41 | PLUMB = "#A23474" 42 | PLUMB_TINT = "#DCB2CA" 43 | PLUMB_SHADE = "#881B5B" 44 | TAUPE = "#895D66" 45 | TAUPE_TINT = "#CFB5BB" 46 | TAUPE_SHADE = "#6A4E54" 47 | STEEL = "#6B6B78" 48 | STEEL_TINT = "#BEBEC6" 49 | STEEL_SHADE = "#5A5A63" 50 | ULTRAMARINE = "#2C6BED" 51 | ULTRAMARINE_TINT = "#B0C8F9" 52 | ULTRAMARINE_SHADE = "#1851B4" 53 | GROUP = "#2C6BED" 54 | GROUP_TINT = "#B0C8F9" 55 | GROUP_SHADE = "#1851B4" 56 | 57 | COLORMAP = { 58 | "red": CRIMSON, 59 | "deep_orange": CRIMSON, 60 | "orange": VERMILLION, 61 | "amber": VERMILLION, 62 | "brown": BURLAP, 63 | "yellow": BURLAP, 64 | "pink": PLUMB, 65 | "purple": VIOLET, 66 | "deep_purple": VIOLET, 67 | "indigo": INDIGO, 68 | "blue": BLUE, 69 | "light_blue": BLUE, 70 | "cyan": TEAL, 71 | "teal": TEAL, 72 | "green": FOREST, 73 | "light_green": WINTERGREEN, 74 | "lime": WINTERGREEN, 75 | "blue_grey": TAUPE, 76 | "grey": STEEL, 77 | "ultramarine": ULTRAMARINE, 78 | "group_color": GROUP, 79 | } 80 | 81 | # Extracted from: 82 | # https://github.com/signalapp/Signal-Android/blob/master/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/AvatarColor.java 83 | # Note that Signal uses ColorInts, which are AARRGGBB. We strip off the alpha. 84 | AVATAR_COLORS = { 85 | "C000": "#D00B0B", 86 | "C010": "#C72A0A", 87 | "C020": "#B34209", 88 | "C030": "#9C5711", 89 | "C040": "#866118", 90 | "C050": "#76681E", 91 | "C060": "#6C6C13", 92 | "C070": "#5E6E0C", 93 | "C080": "#507406", 94 | "C090": "#3D7406", 95 | "C100": "#2D7906", 96 | "C110": "#1A7906", 97 | "C120": "#067906", 98 | "C130": "#067919", 99 | "C140": "#06792D", 100 | "C150": "#067940", 101 | "C160": "#067953", 102 | "C170": "#067462", 103 | "C180": "#067474", 104 | "C190": "#077288", 105 | "C200": "#086DA0", 106 | "C210": "#0A69C7", 107 | "C220": "#0D59F2", 108 | "C230": "#3454F4", 109 | "C240": "#5151F6", 110 | "C250": "#6447F5", 111 | "C260": "#7A3DF5", 112 | "C270": "#8F2AF4", 113 | "C280": "#A20CED", 114 | "C290": "#AF0BD0", 115 | "C300": "#B80AB8", 116 | "C310": "#C20AA3", 117 | "C320": "#C70A88", 118 | "C330": "#CB0B6B", 119 | "C340": "#D00B4D", 120 | "C350": "#D00B2C", 121 | "crimson": "#CF163E", 122 | "vermilion": "#C73F0A", 123 | "burlap ": "#6F6A58", 124 | "forest ": "#3B7845", 125 | "wintergreen": "#1D8663", 126 | "teal": "#077D92", 127 | "blue": "#336BA3", 128 | "indigo ": "#6058CA", 129 | "violet ": "#9932CB", 130 | "plum": "#AA377A", 131 | "taupe": "#8F616A", 132 | "steel": "#71717F", 133 | "unknown": "#71717F", 134 | } 135 | 136 | 137 | def list_colors(): 138 | return sorted(set(list(COLORMAP.keys()) + list(AVATAR_COLORS.keys()))) 139 | 140 | 141 | def get_color(name): 142 | color = COLORMAP.get(name, None) or AVATAR_COLORS.get(name, None) 143 | if not color is None: 144 | return color 145 | logger.warn(f"Unknown color: {name}, using fallback color instead.") 146 | return AVATAR_COLORS["unknown"] 147 | 148 | 149 | def get_random_color(): 150 | return random.choice(list(COLORMAP.keys())) 151 | -------------------------------------------------------------------------------- /signal2html/linkify.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module for linkifying urls in messages 4 | 5 | License: See LICENSE file 6 | 7 | """ 8 | 9 | import logging 10 | 11 | from linkify_it import LinkifyIt 12 | from linkify_it.tlds import TLDS 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def linkify(message: str) -> str: 18 | """Replace text URLs in message with HTML links""" 19 | linkifier = LinkifyIt().tlds(TLDS) 20 | 21 | # Pre-test first for efficiency 22 | if not linkifier.pretest(message): 23 | return message 24 | 25 | # Test if a link is present 26 | if not linkifier.test(message): 27 | return message 28 | 29 | # Find links in message 30 | matches = linkifier.match(message) 31 | if not matches: 32 | return message 33 | 34 | logger.debug(f"Replacing urls in message:\n{message}") 35 | 36 | # Construct new message 37 | new_message = "" 38 | idx = 0 39 | for match in matches: 40 | new_message += message[idx : match.index] 41 | new_message += f'{match.raw}' 42 | idx = match.last_index 43 | new_message += message[idx:] 44 | 45 | logger.debug(f"Replaced urls in message:\n{new_message}") 46 | return new_message 47 | -------------------------------------------------------------------------------- /signal2html/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Models for storing Signal backup objects. 5 | 6 | These are heavily inspired by the database models of the Signal Android app, 7 | but only the necessary fields are kept. 8 | 9 | License: See LICENSE file. 10 | 11 | """ 12 | 13 | import os 14 | 15 | from abc import ABCMeta 16 | from dataclasses import dataclass 17 | from dataclasses import field 18 | from datetime import datetime 19 | from re import sub 20 | from unicodedata import normalize 21 | 22 | from typing import Dict 23 | from typing import List 24 | 25 | 26 | @dataclass 27 | class Recipient: 28 | rid: int 29 | name: str 30 | color: str 31 | isgroup: bool 32 | phone: str 33 | uuid: str 34 | 35 | def __hash__(self): 36 | return hash(self.rid) 37 | 38 | 39 | @dataclass 40 | class Mention: 41 | mention_id: int 42 | name: str 43 | length: int 44 | 45 | 46 | @dataclass 47 | class Quote: 48 | _id: int 49 | author: Recipient 50 | text: str 51 | mentions: Dict[int, Mention] = field(default_factory=lambda: {}) 52 | 53 | 54 | @dataclass 55 | class Attachment: 56 | contentType: str 57 | fileName: str 58 | voiceNote: bool 59 | width: int 60 | height: int 61 | quote: bool 62 | unique_id: str 63 | 64 | 65 | @dataclass 66 | class GroupCallData: 67 | initiator: str 68 | timestamp: datetime 69 | 70 | 71 | @dataclass 72 | class MemberInfo: 73 | name: str 74 | phone: str 75 | match_from_phone: bool 76 | admin: bool 77 | 78 | 79 | @dataclass 80 | class GroupUpdateData: 81 | group_name: str 82 | change_by: MemberInfo 83 | members: List[MemberInfo] = field(default_factory=list) 84 | new_members: List[MemberInfo] = field(default_factory=list) 85 | deleted_members: List[MemberInfo] = field(default_factory=list) 86 | 87 | 88 | @dataclass 89 | class DisplayRecord(metaclass=ABCMeta): 90 | addressRecipient: Recipient # Recipient corresponding to address field 91 | recipient: Recipient 92 | dateSent: int 93 | dateReceived: int 94 | threadId: int 95 | body: str 96 | _type: int 97 | 98 | 99 | @dataclass 100 | class MessageRecord(DisplayRecord): 101 | _id: int 102 | data: any 103 | delivery_receipt_count: int 104 | read_receipt_count: int 105 | 106 | 107 | @dataclass 108 | class Reaction: 109 | recipient: Recipient 110 | what: str 111 | time_sent: datetime 112 | time_received: datetime 113 | 114 | 115 | @dataclass 116 | class MMSMessageRecord(MessageRecord): 117 | quote: Quote 118 | attachments: List[Attachment] 119 | reactions: List[Reaction] 120 | viewed_receipt_count: int 121 | 122 | 123 | @dataclass 124 | class SMSMessageRecord(MessageRecord): 125 | pass 126 | 127 | 128 | @dataclass 129 | class Thread: 130 | _id: int 131 | recipient: Recipient 132 | mms: List[MMSMessageRecord] = field(default_factory=lambda: []) 133 | sms: List[SMSMessageRecord] = field(default_factory=lambda: []) 134 | mentions: Dict[int, Dict[int, Mention]] = field(default_factory=lambda: {}) 135 | members: List[Recipient] = field(default_factory=lambda: []) 136 | 137 | @property 138 | def is_group(self) -> bool: 139 | return self.recipient.isgroup 140 | 141 | @property 142 | def sanephone(self): 143 | """Return a sanitized phone number suitable for use as filename, and fallback on rid. 144 | 145 | NOTE: Phone numbers can be alphanumerical characters especially coming over SMS 146 | Different strings can still clash in principle if they sanitize to the same string. 147 | """ 148 | if self.recipient.phone: 149 | return self._sanitize(self.recipient.phone) 150 | return "#" + str(self.recipient.rid) 151 | 152 | @property 153 | def name(self): 154 | """Return the raw name or other useful identifier, suitable for display.""" 155 | return self.recipient.name.strip() 156 | 157 | @property 158 | def sanename(self): 159 | """Return a sanitized name or other useful identifier, suitable for use 160 | as filename, and fallback on rid.""" 161 | if self.recipient.name: 162 | return self._sanitize(self.recipient.name) 163 | return "#" + str(self.recipient.rid) 164 | 165 | def _sanitize(self, text): 166 | """Sanitize text to use as filename""" 167 | clean = normalize("NFKC", text.strip()) 168 | clean = clean.lstrip(".#") 169 | clean = sub("[^]\\w!@#$%^&'`.=+{}~()[-]", "_", clean) 170 | clean = clean.rstrip("_") 171 | return clean 172 | 173 | def get_thread_dir(self, output_dir: str, make_dir=True) -> str: 174 | return os.path.dirname(self.get_path(output_dir, make_dir=make_dir)) 175 | 176 | def get_path(self, output_dir: str, make_dir=True) -> str: 177 | """Return a path for a thread and try to be clever about merging 178 | contacts. Optionally create the contact directory.""" 179 | dirname = self.sanename 180 | # Use phone number to distinguish threads from the same contact, 181 | # except for groups, which do not have a phone number. 182 | filename = f"{self.sanename if self.is_group else self.sanephone}.html" 183 | path = os.path.join(output_dir, dirname, filename) 184 | i = 2 185 | while os.path.exists(path): 186 | if self.is_group: 187 | dirname = f"{self.sanename}_{i}" 188 | else: 189 | filename = f"{self.sanephone}_{i}.html" 190 | path = os.path.join(output_dir, dirname, filename) 191 | i += 1 192 | if make_dir: 193 | os.makedirs(os.path.dirname(path), exist_ok=True) 194 | return path 195 | -------------------------------------------------------------------------------- /signal2html/templates/thread.html: -------------------------------------------------------------------------------- 1 | {% macro attachment(attach) -%} 2 |
3 | {% if attach.voiceNote or attach.contentType == "audio/mpeg" %} 4 | 8 | {% elif attach.contentType == "video/mp4" or attach.contentType == "video/3gpp" %} 9 | 13 | {% elif attach.contentType == "image/jpeg" or attach.contentType == "image/png" or attach.contentType == "image/gif" or attach.contentType == "image/webp" %} 14 |
15 | 16 | 19 |
20 | {% else %} 21 | Attachment of type {{ attach.contentType }} 22 | {% endif %} 23 |
24 | {%- endmacro %} 25 | {%- macro message_metadata(date, secure, state, isGroup, deliv_count, read_count) -%} 26 | {{ date.strftime(date_time_format) }} 27 | {% if not secure %} 28 | 🔓︎{# Open lock, text variant #} 29 | {%endif%} 30 | {% if state == "DISPLAY_TYPE_PENDING" %} 31 | ◌{# Dotted circle #} 32 | {% elif state == "DISPLAY_TYPE_SENT" %} 33 | ✓{# Checkmark #} 34 | {% elif state == "DISPLAY_TYPE_FAILED" %} 35 | ⚠{# Warning sign #} 36 | {% elif state == "DISPLAY_TYPE_DELIVERED" %} 37 | ✓✓{# Double checkmark #} 38 | {% elif state == "DISPLAY_TYPE_READ" %} 39 | ✓✓✓{# Triple checkmark #} 40 | {% endif%} 41 | {%- endmacro -%} 42 | 43 | 44 | 45 | 46 | Signal2HTML · {{ thread_name }} 47 | 344 | 345 | 346 |
347 |
348 | {{ thread_name }} 349 |
350 |
351 | {{ thread_subtitle }} 352 |
353 |
354 |
355 | {% for msg in messages %} 356 | {% if "date_msg" in msg %} 357 |
358 |

359 | {{ msg.body }} 360 |

361 | {% else %} 362 | {% if msg.type == 'call-incoming' %} 363 |
364 |
365 |
366 | {{ msg.event_data | safe }} called you 367 |
368 | {% elif msg.type == 'call-outgoing' %} 369 |
370 |
371 |
372 | You called 373 |
374 | {% elif msg.type == 'call-missed' %} 375 |
376 |
377 |
378 | Missed call 379 |
380 | {% elif msg.type == 'video-call-incming' %} 381 |
382 |
383 |
384 | Video call from {{ msg.event_data | safe }} 385 |
386 | {% elif msg.type == 'video-call-outgoing' %} 387 |
388 |
389 |
390 | Outgoing video call 391 |
392 | {% elif msg.type == 'video-call-missed' %} 393 |
394 |
395 |
396 | Missed video call 397 |
398 | {% elif msg.type == 'group-call' %} 399 |
400 |
401 | Group call {% if msg.event_data %}started by {{ msg.event_data | safe }}{% endif %} 402 |
403 | {% elif msg.type == 'key-update' %} 404 |
405 |
406 | {{msg.event_data | safe}} has a new safety number 407 |
408 | {% elif msg.type == 'group-update-v1' or msg.type == 'group-update-v2' %} 409 |
410 |
411 | {% if msg.event_data.header %}{{msg.event_data.header}}{% endif %} 412 |
    413 | {% if msg.event_data.name %}
  • Name: {{msg.event_data.name}}{% endif %} 414 | {% for member_list in msg.event_data.member_lists %} 415 |
  • {{member_list.header}} 416 |
      417 | {% for member in member_list.members %} 418 |
    • {{member | safe}} 419 | {% endfor %} 420 |
    421 | {% endfor %} 422 |
423 |
424 | {% if msg.attachments %} 425 | {% for attach in msg.attachments %} 426 |
Group photo
427 | {{ attachment(attach) }} 428 | {% endfor %} 429 | {% endif %} 430 | {% if debug_messages %} 431 | {{msg.body}} 432 | {% endif %} 433 | {% else %} 434 |
435 | {% if msg.isGroup and msg.type == 'incoming' %} 436 | {{ msg.name }} 437 | {% endif %} 438 | {% if msg.quote %} 439 |
440 |
441 | {{ msg.quote.name }} 442 |
{{ msg.quote.body | safe }}
443 |
444 | {% if msg.quote.attachments %} 445 |
446 | {% for attach in msg.quote.attachments %} 447 | {{ attachment(attach) }} 448 | {% endfor %} 449 |
450 | {% endif %} 451 |
452 | {% endif %} 453 | {% if msg.attachments %} 454 | {% for attach in msg.attachments %} 455 | {{ attachment(attach) }} 456 | {% endfor %} 457 | {% endif %} 458 | {% if msg.body %} 459 | {% if msg.isAllEmoji %} 460 |
461 | {% else %} 462 |
463 | {% endif %} 464 |
{{ msg.body | safe }}
465 |
466 | {% endif %} 467 | {% endif %} 468 | {{ message_metadata(msg.date, msg.secure, msg.send_state, msg.isGroup, msg.delivery_receipt_count, msg.read_receipt_count) }} 469 | {% if msg.reactions %} 470 |
471 | {% for reaction in msg.reactions %} 472 | {{reaction.what}}From {{reaction.name}}
Sent {{reaction.time_sent.strftime(date_time_format)}}
Received {{reaction.time_received.strftime(date_time_format)}}
473 | {% endfor %} 474 |
475 | {% endif %} 476 | {% endif %} 477 |
478 | {% endfor %} 479 |
480 | 481 | 482 | -------------------------------------------------------------------------------- /signal2html/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Utilities for dealing with message types 4 | 5 | These are extracted from the Signal Android app source code. 6 | 7 | License: See LICENSE file. 8 | 9 | """ 10 | 11 | from enum import Enum 12 | 13 | BASE_TYPE_MASK = 0x1F 14 | 15 | INCOMING_AUDIO_CALL_TYPE = 1 16 | OUTGOING_AUDIO_CALL_TYPE = 2 17 | MISSED_AUDIO_CALL_TYPE = 3 18 | JOINED_TYPE = 4 19 | UNSUPPORTED_MESSAGE_TYPE = 5 20 | INVALID_MESSAGE_TYPE = 6 21 | MISSED_VIDEO_CALL_TYPE = 8 22 | INCOMING_VIDEO_CALL_TYPE = 10 23 | OUTGOING_VIDEO_CALL_TYPE = 11 24 | GROUP_CALL_TYPE = 12 25 | 26 | KEY_UPDATE_TYPE_BIT = 0x200 27 | 28 | GROUP_CTRL_TYPE_BIT = 0x10000 29 | GROUP_V2_DATA_TYPE_BIT = 0x80000 30 | 31 | SECURE_BIT = 0x800000 32 | 33 | BASE_INBOX_TYPE = 20 34 | BASE_OUTBOX_TYPE = 21 35 | BASE_SENDING_TYPE = 22 36 | BASE_SENT_TYPE = 23 37 | BASE_SENT_FAILED_TYPE = 24 38 | BASE_PENDING_SECURE_SMS_FALLBACK = 25 39 | BASE_PENDING_INSECURE_SMS_FALLBACK = 26 40 | BASE_DRAFT_TYPE = 27 41 | 42 | OUTGOING_MESSAGE_TYPES = [ 43 | BASE_OUTBOX_TYPE, 44 | BASE_SENT_TYPE, 45 | BASE_SENDING_TYPE, 46 | BASE_SENT_FAILED_TYPE, 47 | BASE_PENDING_SECURE_SMS_FALLBACK, 48 | BASE_PENDING_INSECURE_SMS_FALLBACK, 49 | ] 50 | 51 | 52 | class DisplayType(Enum): 53 | DISPLAY_TYPE_NONE = 0 54 | DISPLAY_TYPE_PENDING = 1 55 | DISPLAY_TYPE_SENT = 2 56 | DISPLAY_TYPE_FAILED = 3 57 | DISPLAY_TYPE_DELIVERED = 4 58 | DISPLAY_TYPE_READ = 5 59 | 60 | @classmethod 61 | def from_state( 62 | cls, _type: int, is_delivered: bool = False, is_read: bool = False 63 | ): 64 | if not is_outgoing_message_type(_type): 65 | return cls.DISPLAY_TYPE_NONE 66 | 67 | if is_read: 68 | return cls.DISPLAY_TYPE_READ 69 | 70 | if is_delivered: 71 | return cls.DISPLAY_TYPE_DELIVERED 72 | 73 | base_type = _type & BASE_TYPE_MASK 74 | 75 | if base_type == BASE_SENT_FAILED_TYPE: 76 | return cls.DISPLAY_TYPE_FAILED 77 | 78 | if base_type == BASE_SENT_TYPE: 79 | return cls.DISPLAY_TYPE_SENT 80 | 81 | if ( 82 | base_type == BASE_OUTBOX_TYPE 83 | or base_type == BASE_SENDING_TYPE 84 | or base_type == BASE_PENDING_SECURE_SMS_FALLBACK 85 | or base_type == BASE_PENDING_INSECURE_SMS_FALLBACK 86 | ): 87 | return cls.DISPLAY_TYPE_PENDING 88 | 89 | return cls.DISPLAY_TYPE_NONE 90 | 91 | 92 | def is_outgoing_message_type(_type): 93 | for outgoingType in OUTGOING_MESSAGE_TYPES: 94 | if _type & BASE_TYPE_MASK == outgoingType: 95 | return True 96 | return False 97 | 98 | 99 | def is_inbox_type(_type): 100 | return _type & BASE_TYPE_MASK == BASE_INBOX_TYPE 101 | 102 | 103 | def is_incoming_call(_type): 104 | return _type in (INCOMING_AUDIO_CALL_TYPE, INCOMING_VIDEO_CALL_TYPE) 105 | 106 | 107 | def is_outgoing_call(_type): 108 | return _type in (OUTGOING_AUDIO_CALL_TYPE, OUTGOING_VIDEO_CALL_TYPE) 109 | 110 | 111 | def is_missed_call(_type): 112 | return _type in (MISSED_AUDIO_CALL_TYPE, MISSED_VIDEO_CALL_TYPE) 113 | 114 | 115 | def is_video_call(_type): 116 | return _type in ( 117 | INCOMING_VIDEO_CALL_TYPE, 118 | OUTGOING_VIDEO_CALL_TYPE, 119 | MISSED_VIDEO_CALL_TYPE, 120 | ) 121 | 122 | 123 | def is_group_call(_type): 124 | return _type == GROUP_CALL_TYPE 125 | 126 | 127 | def is_key_update(_type): 128 | return _type & KEY_UPDATE_TYPE_BIT == KEY_UPDATE_TYPE_BIT 129 | 130 | 131 | def is_secure(_type): 132 | return _type & SECURE_BIT == SECURE_BIT 133 | 134 | 135 | def is_group_ctrl(_type): 136 | return _type & GROUP_CTRL_TYPE_BIT == GROUP_CTRL_TYPE_BIT 137 | 138 | 139 | def is_group_v2_data(_type): 140 | return _type & GROUP_V2_DATA_TYPE_BIT == GROUP_V2_DATA_TYPE_BIT 141 | 142 | 143 | def is_joined_type(_type): 144 | return _type & BASE_TYPE_MASK == JOINED_TYPE 145 | 146 | 147 | def get_named_message_type(_type): 148 | if is_group_ctrl(_type): 149 | if is_group_v2_data(_type): 150 | return "group-update-v2" 151 | else: 152 | return "group-update-v1" 153 | elif is_key_update(_type): 154 | return "key-update" 155 | elif is_outgoing_message_type(_type): 156 | return "outgoing" 157 | elif is_inbox_type(_type): 158 | return "incoming" 159 | elif is_incoming_call(_type): 160 | return ( 161 | "video-call-incoming" if is_video_call(_type) else "call-incoming" 162 | ) 163 | elif is_outgoing_call(_type): 164 | return ( 165 | "video-call-outgoing" if is_video_call(_type) else "call-outgoing" 166 | ) 167 | elif is_missed_call(_type): 168 | return "video-call-missed" if is_video_call(_type) else "call-missed" 169 | elif is_group_call(_type): 170 | return "group-call" 171 | elif is_joined_type(_type): 172 | return "joined" 173 | return "unknown" 174 | -------------------------------------------------------------------------------- /signal2html/ui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """User interface for command line script 4 | 5 | License: See LICENSE file. 6 | 7 | """ 8 | 9 | import argparse 10 | 11 | from pathlib import Path 12 | 13 | from . import __version__ 14 | from .core import process_backup 15 | 16 | 17 | def parse_args(): 18 | parser = argparse.ArgumentParser() 19 | parser.add_argument( 20 | "-i", "--input-dir", help="Input directory", required=True, type=Path 21 | ) 22 | parser.add_argument( 23 | "-o", "--output-dir", help="Output directory", required=True, type=Path 24 | ) 25 | parser.add_argument( 26 | "-V", 27 | "--version", 28 | help="Show version and exit", 29 | action="version", 30 | version=__version__, 31 | ) 32 | return parser.parse_args() 33 | 34 | 35 | def main(): 36 | args = parse_args() 37 | process_backup(args.input_dir, args.output_dir) 38 | -------------------------------------------------------------------------------- /signal2html/versioninfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Class grouping version-specific information from the signal database. 5 | 6 | This file in the Signal source can be used to determine when features were 7 | introduced: 8 | 9 | https://github.com/signalapp/Signal-Android/blob/master/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt 10 | 11 | License: See LICENSE file. 12 | 13 | """ 14 | 15 | import logging 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class VersionInfo(object): 21 | def __init__(self, version): 22 | self.version = int(version) 23 | logger.info(f"Found Signal database version: {version}.") 24 | 25 | def is_tested_version(self) -> bool: 26 | """Returns whether the database version has been tested. 27 | 28 | Testing and pull requests welcome.""" 29 | 30 | return self.version in [18, 23, 65, 80, 89, 110] 31 | 32 | def is_addressbook_using_rids(self) -> bool: 33 | """Returns whether the contacts are structured using recipient IDs. 34 | 35 | Previous versions referred to contacts using their phone numbers or a 36 | special group ID.""" 37 | 38 | return self.version >= 24 39 | 40 | def get_reactions_query_column(self) -> str: 41 | """Returns a SQL expression to retrieve reactions to MMS messages.""" 42 | 43 | return "reactions" if self.version >= 37 else "''" 44 | 45 | def are_mentions_supported(self) -> bool: 46 | """Returns whether the mentions table is present.""" 47 | return self.version >= 68 48 | 49 | def get_quote_mentions_query_column(self) -> str: 50 | """Returns a SQL expression to retrieve quote mentions in MMS messages.""" 51 | 52 | return "quote_mentions" if self.are_mentions_supported() else "''" 53 | 54 | def get_viewed_receipt_count_column(self) -> str: 55 | """Returns a SQL expression to retrieve the viewed receipt count of attachments of MMS messages.""" 56 | 57 | return "viewed_receipt_count" if self.version >= 83 else "'0'" 58 | 59 | def get_thread_recipient_id_column(self) -> str: 60 | """Returns SQL expression to retrieve recipient id from thread table""" 61 | return ( 62 | "thread_recipient_id" if self.version >= 108 else "recipient_ids" 63 | ) 64 | -------------------------------------------------------------------------------- /tests/test_linkify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import unittest 5 | 6 | from signal2html.linkify import linkify 7 | 8 | 9 | class TestLinkify(unittest.TestCase): 10 | _CASES = [ 11 | ( 12 | "Lorem ipsum www.example.com dolor sit amet", 13 | 'Lorem ipsum www.example.com dolor sit amet', 14 | ), 15 | ( 16 | "Lorem ipsum example.com dolor sit amet", 17 | 'Lorem ipsum example.com dolor sit amet', 18 | ), 19 | ( 20 | "Lorem ipsum https://www.example.com", 21 | 'Lorem ipsum https://www.example.com', 22 | ), 23 | ("lorem.ipsum.dolor sit amet", "lorem.ipsum.dolor sit amet"), 24 | ( 25 | "Lorem ipsum www.example.yoga dolor sit amet", 26 | 'Lorem ipsum www.example.yoga dolor sit amet', 27 | ), 28 | ( 29 | "Lorem ipsum https://timhein.ninja dolor sit amet", 30 | 'Lorem ipsum https://timhein.ninja dolor sit amet', 31 | ), 32 | ( 33 | "Lorem ipsum test@example.com etc.", 34 | 'Lorem ipsum test@example.com etc.', 35 | ), 36 | ] 37 | 38 | def test_linkify(self): 39 | for message, expected in self._CASES: 40 | with self.subTest(message=message): 41 | self.assertEqual(expected, linkify(message)) 42 | 43 | 44 | if __name__ == "__main__": 45 | unittest.main() 46 | -------------------------------------------------------------------------------- /tests/test_versioninfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import unittest 5 | 6 | from signal2html.versioninfo import VersionInfo 7 | 8 | 9 | class TestVersionInfo(unittest.TestCase): 10 | def test_init(self): 11 | vi = VersionInfo(10) 12 | self.assertEqual(vi.version, 10) 13 | 14 | def test_is_addressbook_using_rids(self): 15 | vi = VersionInfo(10) 16 | self.assertFalse(vi.is_addressbook_using_rids()) 17 | vi = VersionInfo(24) 18 | self.assertTrue(vi.is_addressbook_using_rids()) 19 | vi = VersionInfo(110) 20 | self.assertTrue(vi.is_addressbook_using_rids()) 21 | 22 | def test_get_reactions_query_column(self): 23 | vi = VersionInfo(18) 24 | self.assertEqual(vi.get_reactions_query_column(), "''") 25 | vi = VersionInfo(37) 26 | self.assertEqual(vi.get_reactions_query_column(), "reactions") 27 | vi = VersionInfo(80) 28 | self.assertEqual(vi.get_reactions_query_column(), "reactions") 29 | 30 | def test_are_mentions_supported(self): 31 | vi = VersionInfo(18) 32 | self.assertFalse(vi.are_mentions_supported()) 33 | vi = VersionInfo(68) 34 | self.assertTrue(vi.are_mentions_supported()) 35 | vi = VersionInfo(100) 36 | self.assertTrue(vi.are_mentions_supported()) 37 | 38 | def test_get_quote_mentions_query_column(self): 39 | vi = VersionInfo(18) 40 | self.assertEqual(vi.get_quote_mentions_query_column(), "''") 41 | vi = VersionInfo(68) 42 | self.assertEqual( 43 | vi.get_quote_mentions_query_column(), "quote_mentions" 44 | ) 45 | vi = VersionInfo(100) 46 | self.assertEqual( 47 | vi.get_quote_mentions_query_column(), "quote_mentions" 48 | ) 49 | 50 | def test_get_thread_recipient_id_column(self): 51 | vi = VersionInfo(18) 52 | self.assertEqual(vi.get_thread_recipient_id_column(), "recipient_ids") 53 | vi = VersionInfo(108) 54 | self.assertEqual( 55 | vi.get_thread_recipient_id_column(), "thread_recipient_id" 56 | ) 57 | vi = VersionInfo(110) 58 | self.assertEqual( 59 | vi.get_thread_recipient_id_column(), "thread_recipient_id" 60 | ) 61 | 62 | 63 | if __name__ == "__main__": 64 | unittest.main() 65 | --------------------------------------------------------------------------------