180 Commits

Author SHA1 Message Date
KnugiHK
6896c9663e Bump version 2025-02-16 11:19:17 +08:00
KnugiHK
6dda2eb6d5 Fix handling of empty quoted_content 2025-02-16 11:17:38 +08:00
KnugiHK
1706f3e9e5 Fixed the inclusion of the template files 2025-02-16 10:44:53 +08:00
Knugi
63c27f63bd Update docs.html 2025-02-14 18:30:55 +00:00
KnugiHK
c8b71213ae Remove --iphone 2025-02-09 16:26:08 +08:00
KnugiHK
05505eb3ba Bump version 2025-02-09 16:23:04 +08:00
KnugiHK
88680042ba Merge branch 'dev' 2025-02-09 16:19:45 +08:00
KnugiHK
510b4a7e7d Implement quoted message preview for iOS reply bubble (#28) 2025-02-09 16:13:14 +08:00
KnugiHK
bb26d7b605 Fix the wrong behaviour for reply anchor introduced by the base tag 2025-02-09 15:41:15 +08:00
KnugiHK
dd75ec4b87 Implement backward navigation for splited files 2025-02-09 14:47:05 +08:00
KnugiHK
0b2dfa9aba Implement custom headline (#97) 2025-02-09 14:20:11 +08:00
KnugiHK
539a1d58b0 Add finish line 2025-02-09 14:09:39 +08:00
KnugiHK
f43e1f760d Lazy loading of video for new theme 2025-02-09 14:09:22 +08:00
KnugiHK
bfd172031c Distinguish between regular video and GIF (#103) 2025-02-09 13:54:48 +08:00
KnugiHK
17ec2ecf76 Update the layout of the footer 2025-02-09 13:23:54 +08:00
KnugiHK
f300e017ed Implement lazy loading for video (#103) 2025-02-09 13:23:23 +08:00
KnugiHK
bf993c5302 Change the column to determine if the chat should be filtered (#112) 2025-02-09 12:47:35 +08:00
Knugi
5b3d0e2b3a Update README.md 2025-01-08 15:57:04 +00:00
Knugi
ec7cafd6b6 Update README.md 2025-01-07 16:18:48 +00:00
KnugiHK
23af55d645 Implement empty chat filtering from SQL #112
This commit also removed the old empty chat filtering logic.
2025-01-04 18:18:34 +08:00
KnugiHK
92d710bce8 Differentiate group and personal calls 2025-01-02 20:57:28 +08:00
KnugiHK
7a1fa46368 Implement call log for iOS #122 2025-01-02 20:48:11 +08:00
KnugiHK
cf03bfba1b Bug fix on duplicated base name #126 2025-01-02 17:30:11 +08:00
KnugiHK
a0b8167121 Create a whatsapp-alike theme #97 2025-01-02 16:01:25 +08:00
KnugiHK
7117716e5b Add crypt14 offset 2024-12-19 19:13:21 +08:00
Knugi
a1f6320cd8 Update README.md 2024-12-09 14:12:26 +00:00
KnugiHK
37e329a051 Merge branch 'main' into dev 2024-12-09 22:11:29 +08:00
KnugiHK
a8bac8837e Automatically detect timezone offset when --time-offset is not provided #124 2024-12-08 20:57:38 +08:00
KnugiHK
82d2485778 Fixed the incorrect iOS timestamp #124 2024-12-08 20:42:33 +08:00
KnugiHK
209d5a7796 Migrate to pyproject.toml 2024-12-08 20:36:22 +08:00
KnugiHK
fef9684189 Remove __version__
Use importlib.metadata.version instead
2024-12-08 20:35:54 +08:00
Knugi
0d43d80e23 Update bug_report.md 2024-11-14 04:26:51 +00:00
Knugi
88c2abd5e7 Update bug_report.md 2024-11-14 04:25:59 +00:00
Knugi
379e4bbb7e Update README.md 2024-10-27 03:55:32 +00:00
KnugiHK
fa37dd4b2d Update setup.py 2024-10-24 19:51:00 +08:00
KnugiHK
afa6052a08 Add note 2024-10-24 19:39:19 +08:00
KnugiHK
bde3c18498 Bump version 2024-10-24 19:34:09 +08:00
KnugiHK
af3307825a Update docs 2024-10-24 19:33:54 +08:00
KnugiHK
9b34f7ea6d Merge branch 'main' into dev 2024-09-24 23:37:51 +08:00
Knugi
280a1186d8 Update README.md 2024-09-24 15:37:27 +00:00
KnugiHK
30cff71e76 Add type hints 2024-09-24 23:31:59 +08:00
KnugiHK
05d21e3e5a Rename the functions for size conversion 2024-09-24 23:31:00 +08:00
KnugiHK
fb88c83ac4 Dynamically convert the size unit for outputting JSON 2024-09-24 23:29:11 +08:00
KnugiHK
ffb6aef96e Add handlers for the default and other call state #117 2024-09-24 23:16:40 +08:00
Knugi
77c5a3e20c Update README.md 2024-09-20 16:11:03 +00:00
Knugi
7b0965ac1d Update README.md 2024-09-20 13:25:13 +00:00
KnugiHK
d72b41da11 Wrap caption in a w3-container #111 2024-09-16 21:37:18 +08:00
KnugiHK
fed14ceb29 Bug fix on missing group name 2024-09-15 11:33:23 +08:00
KnugiHK
3e6fdaa126 Bug fix on empty file name for (mostly) ios 2024-09-15 11:09:06 +08:00
KnugiHK
04000c78e2 Implement chat filter with group msg sender for iOS #85 2024-09-15 10:55:43 +08:00
KnugiHK
75c429fe22 Remove blocked code (ios) 2024-09-15 10:43:44 +08:00
KnugiHK
9608fa387d Allow units in --split 2024-09-15 01:27:08 +08:00
KnugiHK
fc9c76c34c Implement chat filter with group msg sender for Android #85 2024-09-15 00:29:57 +08:00
KnugiHK
87b1fcc038 Show caption even if the the message is a metadata #111 2024-09-14 16:10:18 +08:00
KnugiHK
fe88f1b837 Make the iOS contact database optional #76 2024-09-14 15:22:53 +08:00
Knugi
af3d31f773 Update README.md 2024-09-13 12:37:18 +00:00
KnugiHK
df67a549c0 Bug fix on empty contact list when using --enrich-from-vcards 2024-09-11 00:23:13 +08:00
KnugiHK
884ccc4cc0 Bug fix on vobject being a necessary dependency 2024-09-11 00:07:39 +08:00
KnugiHK
484910cf5c Remove blocked code
No plan on implementing this feature
2024-09-10 00:15:25 +08:00
KnugiHK
a83c8eb17f Basic implementation on txt format output #108 2024-09-10 00:14:10 +08:00
KnugiHK
8ffa8cfcac Handle the wording of time unit in calls 2024-09-07 21:26:36 +08:00
KnugiHK
8fcd50d21b Implement unit conversion for picked up calls #115 2024-09-07 20:58:54 +08:00
KnugiHK
f91c527676 Adapting the function of converting size unit from stackoverflow 2024-09-07 20:45:26 +08:00
KnugiHK
f35bf24a5e Add space before <br> to prevent it from being recognized as part of a link #113 2024-09-07 20:30:22 +08:00
KnugiHK
e2684845b8 Bug fix on missing the last message when using --split #114 2024-09-07 18:57:16 +08:00
KnugiHK
df3333f948 Sanitize the file name for --per-chat options #86 2024-09-07 18:45:36 +08:00
KnugiHK
bd4ccbb8ac Add all forbidden options to an error message 2024-09-07 18:31:06 +08:00
KnugiHK
fb5a1c3e1f Bug fix on incomplete JSON output on --per-chat #86 2024-09-07 18:30:38 +08:00
Knugi
1760dea0f5 Merge pull request #106 from Bnaya/bnaya-iteration
[Feat] Import contact names from exported google vcards and more
2024-07-20 13:45:20 +08:00
Knugi
4fcb4df0a4 Merge branch 'dev' into bnaya-iteration 2024-07-20 13:43:32 +08:00
Bnaya Peretz
13904ea4d8 Fix cond 2024-07-15 08:48:15 +03:00
KnugiHK
8069882dc5 Update variable name for filter_empty 2024-07-13 21:54:13 +08:00
KnugiHK
d95b075ac0 Invert the --dont-filter-empty option to provide clarity 2024-07-13 18:57:16 +08:00
KnugiHK
ea01a727cf Fixed the wrong boolean value 2024-07-13 18:33:33 +08:00
Knugi
b2f679d975 Fix --avoid-encoding-json option
Co-authored-by: Bnaya Peretz <me@bnaya.net>
2024-07-13 10:25:44 +00:00
KnugiHK
0cf113561a Rename variable and files 2024-07-13 14:16:57 +08:00
KnugiHK
80bdc4414a Refactoring empty filtering 2024-07-13 14:12:21 +08:00
KnugiHK
09e5e1a756 Update readme for enriching contacts 2024-07-13 13:59:47 +08:00
KnugiHK
6e37061e71 Rename module 2024-07-13 13:52:07 +08:00
KnugiHK
b301dd22d0 Refactoring 2024-07-13 13:47:07 +08:00
KnugiHK
5b97d6013a Refactor core code of mapping vcards 2024-07-13 13:33:47 +08:00
KnugiHK
8f304f1c48 There should be no need for enumerate() 2024-07-13 12:43:03 +08:00
KnugiHK
7bb2fb2420 Refactoring --pretty-print-json and --avoid_encoding_json options 2024-07-13 12:28:01 +08:00
KnugiHK
83fefe585b Refactoring
The added code should be placed above any platform specific code
2024-07-13 12:18:18 +08:00
KnugiHK
4886587065 Make the message clear 2024-07-13 12:15:45 +08:00
KnugiHK
0423fdabda Update contacts_names_from_vcards_test.py 2024-07-13 12:12:55 +08:00
KnugiHK
823ed663e7 The check happened in __main__ already 2024-07-13 12:12:32 +08:00
Bnaya Peretz
be469aed93 Bnaya's assorted features 2024-07-13 12:00:35 +08:00
Knugi
b34045a59f Update bug_report.md 2024-07-02 17:08:04 +00:00
KnugiHK
3461ce3735 Handle the FileNotFoundError in decryption (#98) 2024-07-02 20:34:18 +08:00
KnugiHK
b0942d695b Add base tag to reduce the output size (#103) 2024-06-30 19:17:45 +08:00
KnugiHK
5449646a1b Add lazy loading to image (#103) 2024-06-30 19:17:40 +08:00
KnugiHK
6370b81299 Update import 2024-06-30 18:55:42 +08:00
KnugiHK
c69d053049 Import back the BPListReader 2024-06-30 18:18:26 +08:00
KnugiHK
b01d81ddec Lower the chunk size for decryption 2024-06-22 20:20:57 +08:00
KnugiHK
7e2800d89a Make necessary changes for adopting the latest iphone_backup_decrypt 2024-06-22 20:02:29 +08:00
Knugi
33763b5f41 Update compile-binary.yml 2024-06-09 07:36:58 +00:00
KnugiHK
f080e2d4ea Update compile-binary.yml 2024-06-08 18:54:53 +08:00
KnugiHK
00f666a3c0 Update python-publish.yml 2024-06-08 18:29:58 +08:00
KnugiHK
2ca064d111 Revert back to 3.10 as 3.11 and 3.12 failed 2024-06-08 18:24:05 +08:00
KnugiHK
3b54ca9d28 Change to use OIDC for publishing to PyPi 2024-06-08 18:20:44 +08:00
KnugiHK
03312da6ee Update Python to 12 for Nuitka 2024-06-08 17:57:48 +08:00
KnugiHK
c7e8a603c7 Undefined vCard File 2024-06-08 17:51:44 +08:00
KnugiHK
574b0393d8 Align vCard UX with iOS 2024-06-08 17:50:48 +08:00
KnugiHK
baa79a7b74 Merge branch 'pr/99' into dev 2024-06-08 17:38:51 +08:00
KnugiHK
d57ff29e71 Add link to vcard entry 2024-06-08 17:29:10 +08:00
jonx
2d4d934a91 Handle groups of VCards correctly 2024-05-03 02:36:17 +02:00
Knugi
9741cab078 Merge pull request #93 from mmmeeedddsss/main
Add support for separating media files per chat
2024-04-26 21:38:37 +08:00
KnugiHK
1e7687f8e8 Update the help text for --create-separated-media 2024-04-21 12:35:09 +08:00
KnugiHK
524b3a4034 Implement separate media for iOS also 2024-04-21 12:33:03 +08:00
KnugiHK
1ab4b24fa0 Fix typo 2024-04-21 12:03:41 +08:00
KnugiHK
8d003b217c Refactor a bit and use chat jid as the final fallback 2024-04-21 12:00:25 +08:00
KnugiHK
d754e6c279 Add Django license 2024-04-21 11:37:05 +08:00
Mert Tunc
0eebbcff21 Add support for separating media files per chat 2024-04-15 19:20:33 +03:00
KnugiHK
a569fb0875 Change exit to argparse error 2024-02-24 16:41:33 +08:00
KnugiHK
6e8e0d7f59 Implement per chat json #86 2024-02-24 16:26:15 +08:00
KnugiHK
c0a511adb3 Refactor imports 2024-02-13 16:01:21 +08:00
KnugiHK
e84640de1c Update description 2024-02-13 15:59:14 +08:00
KnugiHK
20199ed794 Rename files and names 2024-02-13 15:58:29 +08:00
KnugiHK
f4e610a953 Rename variable of the date filter in processes 2024-02-13 15:37:06 +08:00
KnugiHK
99a3a4bcd0 Refactor chat condition 2024-02-13 15:33:58 +08:00
KnugiHK
dedfce8feb Improve help menu 2024-02-13 15:11:53 +08:00
KnugiHK
54e0b43888 Improve redirection 2024-02-13 15:03:51 +08:00
KnugiHK
d5ea843286 Add chat filter 2024-02-13 14:52:15 +08:00
KnugiHK
b01fe0ab4a Bug fix on missing table join 2024-02-13 13:57:22 +08:00
KnugiHK
a7ccc3be66 Merge branch 'main' into dev 2024-02-12 18:08:59 +08:00
KnugiHK
07b1cf6a8a Wrong place 2024-02-12 18:08:45 +08:00
KnugiHK
2b49ac2e41 Add date filter for iOS #82 2024-02-12 18:00:26 +08:00
KnugiHK
2466e2542a Add date filter for Android #82 2024-02-12 17:31:29 +08:00
Knugi
af70f6f6f9 Update CNAME 2024-02-12 09:27:09 +00:00
KnugiHK
48c3fa965f Bug fix on wrong table name for old schema 2023-12-29 18:32:38 +08:00
KnugiHK
472c18448c Update the Python version for standalone binaries to 3.11 2023-12-17 14:53:18 +08:00
KnugiHK
810d8c7c8b Revert "Add macos-13-arm64"
This reverts commit f80be81ee6.
2023-12-17 14:53:09 +08:00
KnugiHK
f80be81ee6 Add macos-13-arm64 2023-12-17 14:43:20 +08:00
KnugiHK
0fcaa946e6 Add --no-deployment-flag=self-execution flag for -m option in binary 2023-12-17 14:40:47 +08:00
KnugiHK
1e7953e5fe Update .gitignore 2023-12-17 14:40:27 +08:00
KnugiHK
481656fdeb Bug fix for the different between key file and hex key 2023-12-17 14:40:08 +08:00
KnugiHK
3d155fb48f Bug fix on wrong timestamp variable 2023-12-17 14:37:59 +08:00
KnugiHK
f659a8c171 Make sure all messages are extracted chronologically #64 2023-12-07 23:17:50 +08:00
KnugiHK
3ffb63ed28 Update readme 2023-12-03 20:41:34 +08:00
KnugiHK
94956913e8 Add time zone offset to display time 2023-12-03 20:35:58 +08:00
KnugiHK
7b5a7419f1 Apply ZCONTACTJID as chat ID in vcard process 2023-12-03 20:21:19 +08:00
KnugiHK
d5cef051d3 Fix an incorrect variable 2023-12-03 20:17:28 +08:00
KnugiHK
f81f31d667 Fix attempt to #64 2023-12-03 14:19:49 +08:00
KnugiHK
8c617b721f Create bruteforce_crypt15.py 2023-12-03 13:49:57 +08:00
KnugiHK
0d626519ec Add support for decrypting contact db from crypt15 backup 2023-12-03 13:40:21 +08:00
Knugi
f39d448aa6 Update issue templates 2023-12-03 03:19:00 +00:00
Knugi
2dc433df7c Update issue templates 2023-12-03 03:17:41 +00:00
Knugi
75a8a2e8c5 Update issue templates 2023-12-03 03:16:52 +00:00
KnugiHK
3847836ed6 Fix: use a more reliable way to determine chat #61
This fix also changed how to determine if the message belongs to a group or PM.
2023-12-03 10:27:22 +08:00
KnugiHK
c27f5ee41c Merge branch 'dev' 2023-11-12 14:27:35 +08:00
KnugiHK
e6c43e7e35 Bump version 2023-11-12 14:27:03 +08:00
KnugiHK
c2fa18778f Merge branch 'dev' 2023-11-12 14:09:04 +08:00
KnugiHK
150180fdff Refactor 2023-10-29 18:12:17 +08:00
KnugiHK
86ea938323 Add fields for implementing read receipt 2023-10-29 18:12:16 +08:00
KnugiHK
7da71e84fe Bug fix on missing sender in group chats #67 2023-10-29 18:12:11 +08:00
KnugiHK
efd5ed80b2 Bug fix on ignoring system chat 2023-10-29 17:56:19 +08:00
KnugiHK
efea1d6165 Remove waste 2023-10-29 17:52:58 +08:00
KnugiHK
3082c83bc4 Convert all links to hyperlinks #69 2023-10-24 00:16:49 +08:00
KnugiHK
fc50415afd An attempt to fix #67 2023-10-24 00:05:15 +08:00
KnugiHK
be4adadbd8 Update __main__.py 2023-10-08 11:42:47 +08:00
KnugiHK
8eb05424fd Support preservation of file timestamp for encrypted backup 2023-10-08 11:42:39 +08:00
KnugiHK
380289d1c1 Bug fix on missing argument #65 2023-10-08 10:12:58 +08:00
KnugiHK
91ff882d15 Add option to preserve modify timestamp #65 2023-10-07 23:50:43 +08:00
Knugi
5aad65fff7 Merge pull request #60 from andrp92/dev
Feat: Support for WhatsApp Business (iPhone only)
2023-09-22 21:33:12 +08:00
KnugiHK
decea88028 Better UI
I am too lazy to switch branch.
2023-08-23 16:07:04 +08:00
KnugiHK
a08f44e6ed Fix the potential collision of Whatsapp and Whatsapp Business 2023-08-23 16:06:25 +08:00
Andres Rodriguez
dbd1802dd6 feat: use dynamic domains when encrypted 2023-08-18 01:51:13 -04:00
Andres Rodriguez
b9f123fbea fix: use corresponding identifier 2023-08-17 19:59:11 -04:00
KnugiHK
2944d00ca2 Refactoring so that no file needs to be introduced 2023-08-15 17:40:20 +08:00
KnugiHK
448ba892cc Change the option from --smb to --business 2023-08-15 17:33:56 +08:00
KnugiHK
a5cb46e095 Also make vcard path dynamic in Android 2023-08-15 16:22:43 +08:00
KnugiHK
ee4e95c75f Bug fix on the possible breakage mentioned in #60 2023-08-15 16:22:08 +08:00
Andres Rodriguez
f488894942 fix(whatsapp_business): missing domain ref 2023-08-15 00:46:54 -04:00
Andres Rodriguez
269a59c1e2 feat(whatsapp_business): made vcard path dynamic 2023-08-15 00:36:15 -04:00
Andres Rodriguez
d8b434e169 feat(whatsapp_business)!: args and default values for smb
Added corresponding logic for whatsapp business and default values.
2023-08-15 00:35:32 -04:00
Andres Rodriguez
326b99d860 feat(whatsapp_business)!: extraction script for smb
Duplicated original extract_iphone_media to include smb specfic fileIDs.
2023-08-15 00:32:07 -04:00
KnugiHK
bd2f063cc0 Bug fix on calls without chat #58 2023-08-12 15:51:05 +08:00
Knugi
736292538b Update the command for macOS #55 2023-07-25 08:53:04 +00:00
KnugiHK
d772efe779 Update the sample output screenshot 2023-06-25 20:01:06 +08:00
28 changed files with 2529 additions and 626 deletions

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
# Must have
- WhatsApp version: [WhatsApp version]
- OS: [Android/iOS] - [version]
- Platform: [Linux/Windows/MacOS]
- Exporter's branch and version: [main/dev] - [exporter version]
**Describe the bug**
A clear and concise description of what the bug is.
If it is an error yield by Python, please also provide the trackback
```
[trackback here]
```
# Nice to have
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -12,22 +12,22 @@ jobs:
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.3
pip install .
- name: Build binary with Nuitka
run: |
python -m nuitka --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --follow-imports Whatsapp_Chat_Exporter/__main__.py
python -m nuitka --no-deployment-flag=self-execution --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --follow-imports Whatsapp_Chat_Exporter/__main__.py
cp __main__.bin wtsexporter_linux_x64
sha256sum wtsexporter_linux_x64
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: binary-linux
path: |
@@ -36,22 +36,22 @@ jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.3
pip install .
- name: Build binary with Nuitka
run: |
python -m nuitka --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --assume-yes-for-downloads --follow-imports Whatsapp_Chat_Exporter\__main__.py
python -m nuitka --no-deployment-flag=self-execution --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --assume-yes-for-downloads --follow-imports Whatsapp_Chat_Exporter\__main__.py
copy __main__.exe wtsexporter_x64.exe
Get-FileHash wtsexporter_x64.exe
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: binary-windows
path: |
@@ -60,22 +60,22 @@ jobs:
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.3
pip install .
- name: Build binary with Nuitka
run: |
python -m nuitka --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --follow-imports Whatsapp_Chat_Exporter/__main__.py
python -m nuitka --no-deployment-flag=self-execution --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --follow-imports Whatsapp_Chat_Exporter/__main__.py
cp __main__.bin wtsexporter_macos_x64
shasum -a 256 wtsexporter_macos_x64
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: binary-macos
path: |

View File

@@ -11,6 +11,10 @@ name: Upload Python Package
on:
release:
types: [published]
workflow_dispatch:
permissions:
id-token: write
jobs:
deploy:
@@ -18,9 +22,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
@@ -31,6 +35,3 @@ jobs:
run: python -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}

18
.gitignore vendored
View File

@@ -127,3 +127,21 @@ dmypy.json
# Pyre type checker
.pyre/
# Nuitka
*.build/
*.dist/
*.onefile-build/
*.exe
__main__
# Dev time intermidiates & temp files
result/
WhatsApp/
/*.db
/*.db-*
/myout
/msgstore.db
/myout-json
.vscode/

2
CNAME
View File

@@ -1 +1 @@
wts.knugi.com
wts.knugi.dev

36
LICENSE.django Normal file
View File

@@ -0,0 +1,36 @@
The Whatsapp Chat Exporter is licensed under the MIT license. For more information,
refer to the file LICENSE.
Whatsapp Chat Exporter incorporates code from Django, governed by the three-clause
BSD license—a permissive open-source license. The copyright and license details are
provided below to adhere to Django's terms.
------
Copyright (c) Django Software Foundation and individual contributors.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of Django nor the names of its contributors may be used
to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

103
README.md
View File

@@ -4,12 +4,15 @@
[![Python](https://img.shields.io/pypi/pyversions/Whatsapp-Chat-Exporter)](https://pypi.org/project/Whatsapp-Chat-Exporter/)
A customizable Android and iPhone Whatsapp database parser that will give you the history of your Whatsapp conversations in HTML and JSON. Inspired by [Telegram Chat Export Tool](https://telegram.org/blog/export-and-more).
**If you plan to uninstall WhatsApp or delete your WhatsApp account, please make a backup of your WhatsApp database. You may want to use this exporter again on the same database in the future as the exporter develops**
> [!TIP]
> If you plan to uninstall WhatsApp or delete your WhatsApp account, please make a backup of your WhatsApp database. You may want to use this exporter again on the same database in the future as the exporter develops.
If you would like to support this project, all you need to do is to contribute or share this project! If you think otherwise and want to make a donation, please refer to the [Donation Guide](https://blog.knugi.com/DONATE.html).
# Usage
**Usage in README may be removed in the future. Check the usage in [Wiki](https://github.com/KnugiHK/Whatsapp-Chat-Exporter/wiki)**.
**If you want to use the old release (< 0.5) of the exporter, please follow the [old usage guide](https://github.com/KnugiHK/Whatsapp-Chat-Exporter/wiki/Old-Usage#usage)**.
> [!NOTE]
> Usage in README may be removed in the future. Check the usage in [Wiki](https://github.com/KnugiHK/Whatsapp-Chat-Exporter/wiki)
First, install the exporter by:
```shell
@@ -21,6 +24,10 @@ Then, create a working directory in somewhere you want
mkdir working_wts
cd working_wts
```
> [!TIP]
> macOS users should grant *Full Disk Access* to Terminal in the *Security & Privacy* settings before using the exporter.
## Working with Android
### Unencrypted WhatsApp database
Extract the WhatsApp database with whatever means, one possible means is to use the [WhatsApp-Key-DB-Extractor](https://github.com/KnugiHK/WhatsApp-Key-DB-Extractor)
@@ -35,6 +42,13 @@ Simply invoke the following command from shell.
```sh
wtsexporter -a
```
#### Enriching Contact from vCard
Usually, the default WhatsApp contact database extracted from your phone will contains the contact names and the exporter will use it to map your chats. However, some reported cases showed that the database could has never been populated.
In this case, you can export your contacts to a vCard file from your phone or a cloud provider like Google Contacts. Then, install the necessary dependency and run the following command from the shell:
```sh
pip install whatsapp-chat-exporter["vcards"]
wtsexporter -a --enrich-from-vcard contacts.vcf --default-country-code 852
```
### Encrypted Android WhatsApp Backup
In order to support the decryption, install pycryptodome if it is not installed
@@ -42,7 +56,10 @@ In order to support the decryption, install pycryptodome if it is not installed
pip install pycryptodome # Or
pip install whatsapp-chat-exporter["android_backup"] # install along with this software
```
### Crypt15 is now the easiest way to decrypt a backup. If you have the 32 bytes hex key generated when you enable End-to-End encrypted backup, you can use it to decrypt the backup. If you do not have the 32 bytes hex key, you can still use the key file extracted just like extacting key file for Crypt12 and Crypt14 to decrypt the backup.
> [!TIP]
> Crypt15 is now the easiest way to decrypt a backup. If you have the 32 bytes hex key generated when you enable End-to-End encrypted backup, you can use it to decrypt the backup. If you do not have the 32 bytes hex key, you can still use the key file extracted just like extacting key file for Crypt12 and Crypt14 to decrypt the backup.
#### Crypt12 or Crypt14
You will need the decryption key file from your phone. If you have root access, you can find it as `/data/data/com.whatsapp/files/key`. Otherwise, if you used WhatsApp-Key-DB-Extractor before, it will appear in the WhatsApp backup directory as `WhatsApp/Databases/.nomedia`.
@@ -63,7 +80,10 @@ To support Crypt15 backup, install javaobj-py3 if it is not installed
pip install javaobj-py3 # Or
pip install whatsapp-chat-exporter["crypt15"] # install along with this software
```
Place the encrypted WhatsApp Backup (msgstore.db.crypt15) in the working directory. If you also want the name of your contacts, get the contact database, which is called wa.db. And copy the WhatsApp (Media) directory from your phone directly.
Before proceeding with this method, you must first create an end-to-end encrypted backup. For detailed instructions, refer to [WhatsApp's help center](https://faq.whatsapp.com/490592613091019).
Once you have copied the backup files to your computer, place the encrypted WhatsApp backup file (msgstore.db.crypt15) into the working directory. If you also wish to include your contacts' names, obtain the contact database file, named wa.db. Additionally, copy the WhatsApp Media folder directly from your phone.
If you do not have the 32 bytes hex key (64 hexdigits), place the decryption key file (encrypted_backup.key) extracted from Android. If you gave the 32 bytes hex key, simply put the key in the shell.
Now, you should have something like this in the working directory (if you do not have 32 bytes hex key).
@@ -80,12 +100,13 @@ wtsexporter -a -k 432435053b5204b08e5c3823423399aa30ff061435ab89bc4e6713969cdaa5
```
## Working with iOS/iPadOS (iPhone or iPad)
Do an iPhone/iPad Backup with iTunes first.
Do an iPhone/iPad Backup with iTunes/Finder first.
* iPhone backup on Mac: https://support.apple.com/HT211229
* iPhone backup on Windows: https://support.apple.com/HT212156
* iPad backup: https://support.apple.com/guide/ipad/ipad9a74df05xx/ipados
### Encrypted iOS/iPadOS Backup
**If you are working on unencrypted iOS/iPadOS backup, skip this**
> [!NOTE]
> If you are working on unencrypted iOS/iPadOS backup, skip this.
If you want to work on an encrypted iOS/iPadOS Backup, you should install iphone_backup_decrypt from [KnugiHK/iphone_backup_decrypt](https://github.com/KnugiHK/iphone_backup_decrypt) before you run the extract_iphone_media.py.
```sh
@@ -99,7 +120,7 @@ wtsexporter -i -b "C:\Users\[Username]\AppData\Roaming\Apple Computer\MobileSync
```
#### Mac
```sh
wtsexporter -i -b "~/Library/Application Support/MobileSync/Backup/[device id]"
wtsexporter -i -b ~/Library/Application\ Support/MobileSync/Backup/[device id]
```
## Results
@@ -110,30 +131,42 @@ After extracting, you will get these:
#### Group Message
![Group Message](imgs/group.png)
*The above screenshots were taken longgggggggggggggg ago. I am going to update them when possible.*
## More options
Invoke the wtsexporter with --help option will show you all options available.
```sh
> wtsexporter --help
usage: wtsexporter [-h] [-a] [-i] [-e EXPORTED] [-w WA] [-m MEDIA] [-b BACKUP] [-o OUTPUT] [-j [JSON]] [-d DB] [-k KEY] [-t TEMPLATE] [-s] [-c] [--offline OFFLINE] [--size [SIZE]]
[--no-html] [--check-update] [--assume-first-as-me]
usage: wtsexporter [-h] [-a] [-i] [-e EXPORTED] [-w WA] [-m MEDIA] [-b BACKUP] [-o OUTPUT] [-j [JSON]]
[--avoid-encoding-json] [--pretty-print-json [PRETTY_PRINT_JSON]] [-d DB] [-k KEY] [-t TEMPLATE]
[-s] [-c] [--offline OFFLINE] [--size [SIZE]] [--no-html] [--check-update] [--assume-first-as-me]
[--no-avatar] [--import] [--business] [--wab WAB] [--time-offset {-12 to 14}] [--date DATE]
[--date-format FORMAT] [--include [phone number ...]] [--exclude [phone number ...]]
[--dont-filter-empty] [--per-chat] [--create-separated-media]
[--decrypt-chunk-size DECRYPT_CHUNK_SIZE] [--enrich-from-vcards ENRICH_FROM_VCARDS]
[--default-country-code DEFAULT_CONTRY_CODE] [--txt [TEXT_FORMAT]] [--experimental-new-theme]
[--call-db [CALL_DB_IOS]] [--headline HEADLINE]
A customizable Android and iOS/iPadOS WhatsApp database parser that will give you the history of your WhatsApp
conversations in HTML and JSON. Android Backup Crypt12, Crypt14 and Crypt15 supported.
options:
-h, --help show this help message and exit
-a, --android Define the target as Android
-i, --iphone, --ios Define the target as iPhone/iPad
-i, --ios, Define the target as iPhone/iPad
-e EXPORTED, --exported EXPORTED
Define the target as exported chat file and specify the path to the file
-w WA, --wa WA Path to contact database (default: wa.db/ContactsV2.sqlite)
-m MEDIA, --media MEDIA
Path to WhatsApp media folder (default: WhatsApp)
-b BACKUP, --backup BACKUP
Path to Android (must be used together with -k)/iPhone WhatsApp backup
Path to Android (must be used together with -k)/iOS WhatsApp backup
-o OUTPUT, --output OUTPUT
Output to specific directory (default: result)
-j [JSON], --json [JSON]
Save the result to a single JSON file (default if present: result.json)
--avoid-encoding-json
Don't encode non-ascii characters in the output JSON files
--pretty-print-json [PRETTY_PRINT_JSON]
Pretty print the output JSON.
-d DB, --db DB Path to database file (default: msgstore.db/7c7fba66680ef796b916b067077cc246adacf01d)
-k KEY, --key KEY Path to key file
-t TEMPLATE, --template TEMPLATE
@@ -146,8 +179,42 @@ options:
--no-html Do not output html files
--check-update Check for updates (require Internet access)
--assume-first-as-me Assume the first message in a chat as sent by me (must be used together with -e)
--no-avatar Do not render avatar in HTML output
--import Import JSON file and convert to HTML output
--business Use Whatsapp Business default files (iOS only)
--wab WAB, --wa-backup WAB
Path to contact database in crypt15 format
--time-offset {-12 to 14}
Offset in hours (-12 to 14) for time displayed in the output
--date DATE The date filter in specific format (inclusive)
--date-format FORMAT The date format for the date filter
--include [phone number ...]
Include chats that match the supplied phone number
--exclude [phone number ...]
Exclude chats that match the supplied phone number
--dont-filter-empty By default, the exporter will not render chats with no valid message. Setting this flag will
cause the exporter to render those. This is useful if chat(s) are missing from the output
--per-chat Output the JSON file per chat
--create-separated-media
Create a copy of the media seperated per chat in <MEDIA>/separated/ directory
--decrypt-chunk-size DECRYPT_CHUNK_SIZE
Specify the chunk size for decrypting iOS backup, which may affect the decryption speed.
--enrich-from-vcards ENRICH_FROM_VCARDS
Path to an exported vcf file from Google contacts export. Add names missing from WhatsApp's
default database
--default-country-code DEFAULT_CONTRY_CODE
Use with --enrich-from-vcards. When numbers in the vcf file does not have a country code, this
will be used. 1 is for US, 66 for Thailand etc. Most likely use the number of your own country
--txt [TEXT_FORMAT] Export chats in text format similar to what WhatsApp officially provided (default if present:
result/)
--experimental-new-theme
Use the newly designed WhatsApp-alike theme
--call-db [CALL_DB_IOS]
Path to call database (default: 1b432994e958845fffe8e2f190f26d1511534088) iOS only
--headline HEADLINE The custom headline for the HTML output. Use '??' as a placeholder for the chat name
WhatsApp Chat Exporter: 0.9.5 Licensed with MIT
WhatsApp Chat Exporter: 0.11.2 Licensed with MIT. See https://wts.knugi.dev/docs?dest=osl for all open source
licenses.
```
# To do
@@ -156,6 +223,10 @@ See [issues](https://github.com/KnugiHK/Whatsapp-Chat-Exporter/issues).
# Copyright
This is a MIT licensed project.
The Telegram Desktop's export is the reference for whatsapp.html in this repo
The Telegram Desktop's export is the reference for whatsapp.html in this repo.
`bplist.py` was released by Vladimir "Farcaller" Pouzanov under MIT license.
Please also refer to any files prefixed with `LICENSE` to obtain copies of the various licenses.
WhatsApp Chat Exporter is not affiliated, associated, authorized, endorsed by, or in any way officially connected with the WhatsApp LLC, or any of its subsidiaries or its affiliates. The official WhatsApp LLC website can be found at https://www.whatsapp.com/.

View File

@@ -1,3 +0,0 @@
#!/usr/bin/python3
__version__ = "0.9.5"

View File

@@ -1,29 +1,37 @@
#!/usr/bin/python3
import io
import os
import sqlite3
import shutil
import json
import string
import glob
from Whatsapp_Chat_Exporter import extract_exported, extract_iphone
from Whatsapp_Chat_Exporter import extract, extract_iphone_media
from Whatsapp_Chat_Exporter.data_model import ChatStore
from Whatsapp_Chat_Exporter.utility import Crypt, check_update, import_from_json
from argparse import ArgumentParser, SUPPRESS
from sys import exit
try:
from .__init__ import __version__
except ImportError:
from Whatsapp_Chat_Exporter.__init__ import __version__
import vobject
except ModuleNotFoundError:
vcards_deps_installed = False
else:
from Whatsapp_Chat_Exporter.vcards_contacts import ContactsFromVCards
vcards_deps_installed = True
from Whatsapp_Chat_Exporter import exported_handler, android_handler
from Whatsapp_Chat_Exporter import ios_handler, ios_media_handler
from Whatsapp_Chat_Exporter.data_model import ChatStore
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, Crypt, DbType, readable_to_bytes, check_update
from Whatsapp_Chat_Exporter.utility import import_from_json, sanitize_filename, bytes_to_readable
from argparse import ArgumentParser, SUPPRESS
from datetime import datetime
from sys import exit
import importlib.metadata
def main():
parser = ArgumentParser(
description = 'A customizable Android and iPhone WhatsApp database parser that '
'will give you the history of your WhatsApp conversations inHTML '
description = 'A customizable Android and iOS/iPadOS WhatsApp database parser that '
'will give you the history of your WhatsApp conversations in HTML '
'and JSON. Android Backup Crypt12, Crypt14 and Crypt15 supported.',
epilog = f'WhatsApp Chat Exporter: {__version__} Licensed with MIT'
epilog = f'WhatsApp Chat Exporter: {importlib.metadata.version("whatsapp_chat_exporter")} Licensed with MIT. See '
'https://wts.knugi.dev/docs?dest=osl for all open source licenses.'
)
parser.add_argument(
'-a',
@@ -34,9 +42,8 @@ def main():
help="Define the target as Android")
parser.add_argument(
'-i',
'--iphone',
'--ios',
dest='iphone',
dest='ios',
default=False,
action='store_true',
help="Define the target as iPhone/iPad")
@@ -65,7 +72,7 @@ def main():
dest="backup",
default=None,
help="Path to Android (must be used together "
"with -k)/iPhone WhatsApp backup")
"with -k)/iOS WhatsApp backup")
parser.add_argument(
"-o",
"--output",
@@ -81,6 +88,20 @@ def main():
type=str,
const="result.json",
help="Save the result to a single JSON file (default if present: result.json)")
parser.add_argument(
'--avoid-encoding-json',
dest='avoid_encoding_json',
default=False,
action='store_true',
help="Don't encode non-ascii characters in the output JSON files")
parser.add_argument(
'--pretty-print-json',
dest='pretty_print_json',
default=None,
nargs='?',
const=2,
type=int,
help="Pretty print the output JSON.")
parser.add_argument(
'-d',
'--db',
@@ -137,7 +158,6 @@ def main():
"--split",
dest="size",
nargs='?',
type=int,
const=0,
default=None,
help="Maximum (rough) size of a single output file in bytes, 0 for auto"
@@ -177,6 +197,131 @@ def main():
action='store_true',
help="Import JSON file and convert to HTML output"
)
parser.add_argument(
"--business",
dest="business",
default=False,
action='store_true',
help="Use Whatsapp Business default files (iOS only)"
)
parser.add_argument(
"--wab",
"--wa-backup",
dest="wab",
default=None,
help="Path to contact database in crypt15 format"
)
parser.add_argument(
"--time-offset",
dest="timezone_offset",
default=0,
type=int,
choices=range(-12, 15),
metavar="{-12 to 14}",
help="Offset in hours (-12 to 14) for time displayed in the output"
)
parser.add_argument(
"--date",
dest="filter_date",
default=None,
metavar="DATE",
help="The date filter in specific format (inclusive)"
)
parser.add_argument(
"--date-format",
dest="filter_date_format",
default="%Y-%m-%d %H:%M",
metavar="FORMAT",
help="The date format for the date filter"
)
parser.add_argument(
"--include",
dest="filter_chat_include",
nargs='*',
metavar="phone number",
help="Include chats that match the supplied phone number"
)
parser.add_argument(
"--exclude",
dest="filter_chat_exclude",
nargs='*',
metavar="phone number",
help="Exclude chats that match the supplied phone number"
)
parser.add_argument(
"--dont-filter-empty",
dest="filter_empty",
default=True,
action='store_false',
help=("By default, the exporter will not render chats with no valid message. "
"Setting this flag will cause the exporter to render those. "
"This is useful if chat(s) are missing from the output")
)
parser.add_argument(
"--per-chat",
dest="json_per_chat",
default=False,
action='store_true',
help="Output the JSON file per chat"
)
parser.add_argument(
"--create-separated-media",
dest="separate_media",
default=False,
action='store_true',
help="Create a copy of the media seperated per chat in <MEDIA>/separated/ directory"
)
parser.add_argument(
"--decrypt-chunk-size",
dest="decrypt_chunk_size",
default=1 * 1024 * 1024,
type=int,
help="Specify the chunk size for decrypting iOS backup, which may affect the decryption speed."
)
parser.add_argument(
"--enrich-from-vcards",
dest="enrich_from_vcards",
default=None,
help="Path to an exported vcf file from Google contacts export. Add names missing from WhatsApp's default database"
)
parser.add_argument(
"--default-country-code",
dest="default_contry_code",
default=None,
help="Use with --enrich-from-vcards. When numbers in the vcf file does not have a country code, this will be used. 1 is for US, 66 for Thailand etc. Most likely use the number of your own country"
)
parser.add_argument(
"--txt",
dest="text_format",
nargs='?',
default=None,
type=str,
const="result",
help="Export chats in text format similar to what WhatsApp officially provided (default if present: result/)"
)
parser.add_argument(
"--experimental-new-theme",
dest="whatsapp_theme",
default=False,
action='store_true',
help="Use the newly designed WhatsApp-alike theme"
)
parser.add_argument(
"--call-db",
dest="call_db_ios",
nargs='?',
default=None,
type=str,
const="1b432994e958845fffe8e2f190f26d1511534088",
help="Path to call database (default: 1b432994e958845fffe8e2f190f26d1511534088) iOS only"
)
parser.add_argument(
"--headline",
dest="headline",
default="Chat history with ??",
help="The custom headline for the HTML output. Use '??' as a placeholder for the chat name"
)
args = parser.parse_args()
# Check for updates
@@ -184,34 +329,101 @@ def main():
exit(check_update())
# Sanity checks
if args.android and args.iphone and args.exported and args.import_json:
print("You must define only one device type.")
exit(1)
if not args.android and not args.iphone and not args.exported and not args.import_json:
print("You must define the device type.")
exit(1)
if args.no_html and not args.json:
print("You must either specify a JSON output file or enable HTML output.")
exit(1)
if args.import_json and (args.android or args.iphone or args.exported or args.no_html):
print("You can only use --import with -j and without --no-html.")
exit(1)
if args.android and args.ios and args.exported and args.import_json:
parser.error("You must define only one device type.")
if not args.android and not args.ios and not args.exported and not args.import_json:
parser.error("You must define the device type.")
if args.no_html and not args.json and not args.text_format:
parser.error("You must either specify a JSON output file, text file output directory or enable HTML output.")
if args.import_json and (args.android or args.ios or args.exported or args.no_html):
parser.error("You can only use --import with -j and without --no-html, -a, -i, -e.")
elif args.import_json and not os.path.isfile(args.json):
print("JSON file not found.")
exit(1)
parser.error("JSON file not found.")
if args.android and args.business:
parser.error("WhatsApp Business is only available on iOS for now.")
if "??" not in args.headline:
parser.error("--headline must contain '??' for replacement.")
if args.json_per_chat and (
(args.json[-5:] != ".json" and os.path.isfile(args.json)) or \
(args.json[-5:] == ".json" and os.path.isfile(args.json[:-5]))
):
parser.error("When --per-chat is enabled, the destination of --json must be a directory.")
if args.enrich_from_vcards is not None and args.default_contry_code is None:
parser.error("When --enrich-from-vcards is provided, you must also set --default-country-code")
if args.size is not None and not isinstance(args.size, int) and not args.size.isnumeric():
try:
args.size = readable_to_bytes(args.size)
except ValueError:
parser.error("The value for --split must be ended in pure bytes or with a proper unit (e.g., 1048576 or 1MB)")
if args.filter_date is not None:
if " - " in args.filter_date:
start, end = args.filter_date.split(" - ")
start = int(datetime.strptime(start, args.filter_date_format).timestamp())
end = int(datetime.strptime(end, args.filter_date_format).timestamp())
if start < 1009843200 or end < 1009843200:
parser.error("WhatsApp was first released in 2009...")
if start > end:
parser.error("The start date cannot be a moment after the end date.")
if args.android:
args.filter_date = f"BETWEEN {start}000 AND {end}000"
elif args.ios:
args.filter_date = f"BETWEEN {start - APPLE_TIME} AND {end - APPLE_TIME}"
else:
_timestamp = int(datetime.strptime(args.filter_date[2:], args.filter_date_format).timestamp())
if _timestamp < 1009843200:
parser.error("WhatsApp was first released in 2009...")
if args.filter_date[:2] == "> ":
if args.android:
args.filter_date = f">= {_timestamp}000"
elif args.ios:
args.filter_date = f">= {_timestamp - APPLE_TIME}"
elif args.filter_date[:2] == "< ":
if args.android:
args.filter_date = f"<= {_timestamp}000"
elif args.ios:
args.filter_date = f"<= {_timestamp - APPLE_TIME}"
else:
parser.error("Unsupported date format. See https://wts.knugi.dev/docs?dest=date")
if args.whatsapp_theme:
args.template = "whatsapp_new.html"
if args.filter_chat_include is not None and args.filter_chat_exclude is not None:
parser.error("Chat inclusion and exclusion filters cannot be used together.")
if args.filter_chat_include is not None:
for chat in args.filter_chat_include:
if not chat.isnumeric():
parser.error("Enter a phone number in the chat filter. See https://wts.knugi.dev/docs?dest=chat")
if args.filter_chat_exclude is not None:
for chat in args.filter_chat_exclude:
if not chat.isnumeric():
parser.error("Enter a phone number in the chat filter. See https://wts.knugi.dev/docs?dest=chat")
filter_chat = (args.filter_chat_include, args.filter_chat_exclude)
data = {}
if args.enrich_from_vcards is not None:
if not vcards_deps_installed:
parser.error(
"You don't have the dependency to enrich contacts with vCard.\n"
"Read more on how to deal with enriching contacts:\n"
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/blob/main/README.md#usage"
)
contact_store = ContactsFromVCards()
contact_store.load_vcf_file(args.enrich_from_vcards, args.default_contry_code)
if args.android:
contacts = extract.contacts
messages = extract.messages
media = extract.media
vcard = extract.vcard
create_html = extract.create_html
contacts = android_handler.contacts
messages = android_handler.messages
media = android_handler.media
vcard = android_handler.vcard
create_html = android_handler.create_html
if args.db is None:
msg_db = "msgstore.db"
else:
msg_db = args.db
if args.wa is None:
contact_db = "wa.db"
else:
contact_db = args.wa
if args.key is not None:
if args.backup is None:
print("You must specify the backup file with -b")
@@ -228,7 +440,20 @@ def main():
elif all(char in string.hexdigits for char in args.key):
key = bytes.fromhex(args.key)
db = open(args.backup, "rb").read()
error = extract.decrypt_backup(db, key, msg_db, crypt, args.showkey)
if args.wab:
wab = open(args.wab, "rb").read()
error_wa = android_handler.decrypt_backup(wab, key, contact_db, crypt, args.showkey, DbType.CONTACT)
if isinstance(key, io.IOBase):
key.seek(0)
else:
error_wa = 0
error_message = android_handler.decrypt_backup(db, key, msg_db, crypt, args.showkey, DbType.MESSAGE)
if error_wa != 0:
error = error_wa
elif error_message != 0:
error = error_message
else:
error = 0
if error != 0:
if error == 1:
print("Dependencies of decrypt_backup and/or extract_encrypted_key"
@@ -241,10 +466,6 @@ def main():
else:
print("Unknown error occurred.", error)
exit(5)
if args.wa is None:
contact_db = "wa.db"
else:
contact_db = args.wa
if args.media is None:
args.media = "WhatsApp"
@@ -252,27 +473,25 @@ def main():
with sqlite3.connect(contact_db) as db:
db.row_factory = sqlite3.Row
contacts(db, data)
elif args.iphone:
import sys
if "--iphone" in sys.argv:
print(
"WARNING: The --iphone flag is deprecated and will"
"be removed in the future. Use --ios instead."
)
contacts = extract_iphone.contacts
messages = extract_iphone.messages
media = extract_iphone.media
vcard = extract_iphone.vcard
create_html = extract.create_html
elif args.ios:
contacts = ios_handler.contacts
messages = ios_handler.messages
media = ios_handler.media
vcard = ios_handler.vcard
create_html = android_handler.create_html
if args.business:
from Whatsapp_Chat_Exporter.utility import WhatsAppBusinessIdentifier as identifiers
else:
from Whatsapp_Chat_Exporter.utility import WhatsAppIdentifier as identifiers
if args.media is None:
args.media = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
args.media = identifiers.DOMAIN
if args.backup is not None:
if not os.path.isdir(args.media):
extract_iphone_media.extract_media(args.backup)
ios_media_handler.extract_media(args.backup, identifiers, args.decrypt_chunk_size)
else:
print("WhatsApp directory already exists, skipping WhatsApp file extraction.")
if args.db is None:
msg_db = "7c7fba66680ef796b916b067077cc246adacf01d"
msg_db = identifiers.MESSAGE
else:
msg_db = args.db
if args.wa is None:
@@ -288,12 +507,19 @@ def main():
if os.path.isfile(msg_db):
with sqlite3.connect(msg_db) as db:
db.row_factory = sqlite3.Row
messages(db, data, args.media)
media(db, data, args.media)
vcard(db, data)
messages(db, data, args.media, args.timezone_offset, args.filter_date, filter_chat, args.filter_empty)
media(db, data, args.media, args.filter_date, filter_chat, args.filter_empty, args.separate_media)
vcard(db, data, args.media, args.filter_date, filter_chat, args.filter_empty)
if args.android:
extract.calls(db, data)
android_handler.calls(db, data, args.timezone_offset, filter_chat)
elif args.ios and args.call_db_ios is not None:
with sqlite3.connect(args.call_db_ios) as cdb:
cdb.row_factory = sqlite3.Row
ios_handler.calls(cdb, data, args.timezone_offset, filter_chat)
if not args.no_html:
if args.enrich_from_vcards is not None and not contact_store.is_empty():
contact_store.enrich_from_vcards(data)
create_html(
data,
args.output,
@@ -301,14 +527,16 @@ def main():
args.embedded,
args.offline,
args.size,
args.no_avatar
args.no_avatar,
args.whatsapp_theme,
args.headline
)
else:
print(
"The message database does not exist. You may specify the path "
"to database file with option -d or check your provided path."
)
exit(2)
exit(6)
if os.path.isdir(args.media):
media_path = os.path.join(args.output, args.media)
@@ -328,36 +556,71 @@ def main():
print("\nCannot remove original WhatsApp directory. "
"Perhaps the directory is opened?", end="\n")
elif args.exported:
extract_exported.messages(args.exported, data, args.assume_first_as_me)
exported_handler.messages(args.exported, data, args.assume_first_as_me)
if not args.no_html:
extract.create_html(
android_handler.create_html(
data,
args.output,
args.template,
args.embedded,
args.offline,
args.size
args.size,
args.no_avatar,
args.whatsapp_theme,
args.headline
)
for file in glob.glob(r'*.*'):
shutil.copy(file, args.output)
elif args.import_json:
import_from_json(args.json, data)
extract.create_html(
android_handler.create_html(
data,
args.output,
args.template,
args.embedded,
args.offline,
args.size
args.size,
args.no_avatar,
args.whatsapp_theme,
args.headline
)
if args.text_format:
print("Writing text file...")
android_handler.create_txt(data, args.text_format)
if args.json and not args.import_json:
if args.enrich_from_vcards is not None and not contact_store.is_empty():
contact_store.enrich_from_vcards(data)
if isinstance(data[next(iter(data))], ChatStore):
data = {jik: chat.to_json() for jik, chat in data.items()}
with open(args.json, "w") as f:
data = json.dumps(data)
print(f"\nWriting JSON file...({int(len(data)/1024/1024)}MB)")
f.write(data)
if not args.json_per_chat:
with open(args.json, "w") as f:
data = json.dumps(
data,
ensure_ascii=not args.avoid_encoding_json,
indent=args.pretty_print_json
)
print(f"\nWriting JSON file...({bytes_to_readable(len(data))})")
f.write(data)
else:
if args.json[-5:] == ".json":
args.json = args.json[:-5]
total = len(data.keys())
if not os.path.isdir(args.json):
os.mkdir(args.json)
for index, jik in enumerate(data.keys()):
if data[jik]["name"] is not None:
contact = data[jik]["name"].replace('/', '')
else:
contact = jik.replace('+', '')
with open(f"{args.json}/{sanitize_filename(contact)}.json", "w") as f:
file_content_to_write = json.dumps({jik: data[jik]}, ensure_ascii=not args.avoid_encoding_json, indent=2 if args.pretty_print_json else None)
f.write(file_content_to_write)
print(f"Writing JSON file...({index + 1}/{total})", end="\r")
print()
else:
print()

View File

@@ -1,21 +1,21 @@
#!/usr/bin/python3
import sqlite3
import json
import jinja2
import os
import shutil
import re
import io
import hmac
import shutil
from pathlib import Path
from mimetypes import MimeTypes
from markupsafe import escape as htmle
from hashlib import sha256
from base64 import b64decode, b64encode
from datetime import datetime
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
from Whatsapp_Chat_Exporter.utility import MAX_SIZE, ROW_SIZE, determine_metadata, get_status_location
from Whatsapp_Chat_Exporter.utility import CURRENT_TZ_OFFSET, MAX_SIZE, ROW_SIZE, DbType, convert_time_unit, determine_metadata, get_cond_for_empty
from Whatsapp_Chat_Exporter.utility import rendering, Crypt, Device, get_file_name, setup_template
from Whatsapp_Chat_Exporter.utility import brute_force_offset, CRYPT14_OFFSETS
from Whatsapp_Chat_Exporter.utility import brute_force_offset, CRYPT14_OFFSETS, get_status_location
from Whatsapp_Chat_Exporter.utility import get_chat_condition, slugify, bytes_to_readable, JidType
try:
import zlib
@@ -53,7 +53,7 @@ def _extract_encrypted_key(keyfile):
return _generate_hmac_of_hmac(key_stream)
def decrypt_backup(database, key, output, crypt=Crypt.CRYPT14, show_crypt15=False):
def decrypt_backup(database, key, output, crypt=Crypt.CRYPT14, show_crypt15=False, db_type=DbType.MESSAGE):
if not support_backup:
return 1
if isinstance(key, io.IOBase):
@@ -83,8 +83,12 @@ def decrypt_backup(database, key, output, crypt=Crypt.CRYPT14, show_crypt15=Fals
if len(database) < 131:
raise ValueError("The crypt15 file must be at least 131 bytes")
t1 = t2 = None
iv = database[8:24]
db_offset = database[0] + 2 # Skip protobuf + protobuf size and backup type
if db_type == DbType.MESSAGE:
iv = database[8:24]
db_offset = database[0] + 2 # Skip protobuf + protobuf size and backup type
elif db_type == DbType.CONTACT:
iv = database[7:23]
db_offset = database[0] + 1 # Skip protobuf + protobuf size
db_ciphertext = database[db_offset:]
if t1 != t2:
@@ -154,7 +158,11 @@ def contacts(db, data):
c = db.cursor()
c.execute("""SELECT count() FROM wa_contacts""")
total_row_number = c.fetchone()[0]
print(f"Processing contacts...({total_row_number})")
if total_row_number == 0:
print("No contacts profiles found in the default database, consider using --enrich-from-vcards for adopting names from exported contacts from Google")
return False
else:
print(f"Processing contacts...({total_row_number})")
c.execute("""SELECT jid, COALESCE(display_name, wa_name) as display_name, status FROM wa_contacts; """)
row = c.fetchone()
@@ -165,19 +173,41 @@ def contacts(db, data):
row = c.fetchone()
def messages(db, data, media_folder):
def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat, filter_empty):
# Get message history
c = db.cursor()
try:
c.execute("""SELECT count() FROM messages""")
c.execute(f"""SELECT count()
FROM messages
INNER JOIN jid
ON messages.key_remote_jid = jid.raw_string
LEFT JOIN chat
ON chat.jid_row_id = jid._id
WHERE 1=1
{get_cond_for_empty(filter_empty, "messages.key_remote_jid", "messages.needs_push")}
{f'AND timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "messages.remote_resource"], "jid", "android")}
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "messages.remote_resource"], "jid", "android")}""")
except sqlite3.OperationalError:
c.execute("""SELECT count() FROM message""")
c.execute(f"""SELECT count()
FROM message
LEFT JOIN chat
ON chat._id = message.chat_row_id
INNER JOIN jid
ON jid._id = chat.jid_row_id
LEFT JOIN jid jid_group
ON jid_group._id = message.sender_jid_row_id
WHERE 1=1
{get_cond_for_empty(filter_empty, "jid.raw_string", "broadcast")}
{f'AND timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")}
{get_chat_condition(filter_chat[1], False, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")}""")
total_row_number = c.fetchone()[0]
print(f"Processing messages...(0/{total_row_number})", end="\r")
phone_number_re = re.compile(r"[0-9]+@s.whatsapp.net")
try:
c.execute("""SELECT messages.key_remote_jid,
c.execute(f"""SELECT messages.key_remote_jid,
messages._id,
messages.key_from_me,
messages.timestamp,
@@ -193,12 +223,18 @@ def messages(db, data, media_folder):
messages.key_id,
messages_quotes.data as quoted_data,
messages.media_caption,
missed_call_logs.video_call,
missed_call_logs.video_call,
chat.subject as chat_subject,
message_system.action_type,
message_system_group.is_me_joined,
jid_old.raw_string as old_jid,
jid_new.raw_string as new_jid
jid_new.raw_string as new_jid,
jid_global.type as jid_type,
group_concat(receipt_user.receipt_timestamp) as receipt_timestamp,
group_concat(messages.received_timestamp) as received_timestamp,
group_concat(receipt_user.read_timestamp) as read_timestamp,
group_concat(receipt_user.played_timestamp) as played_timestamp,
group_concat(messages.read_device_timestamp) as read_device_timestamp
FROM messages
LEFT JOIN messages_quotes
ON messages.quoted_row_id = messages_quotes._id
@@ -218,11 +254,19 @@ def messages(db, data, media_folder):
ON jid_old._id = message_system_number_change.old_jid_row_id
LEFT JOIN jid jid_new
ON jid_new._id = message_system_number_change.new_jid_row_id
WHERE messages.key_remote_jid <> '-1';"""
LEFT JOIN receipt_user
ON receipt_user.message_row_id = messages._id
WHERE messages.key_remote_jid <> '-1'
{get_cond_for_empty(filter_empty, "messages.key_remote_jid", "messages.needs_push")}
{f'AND messages.timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "messages.remote_resource"], "jid_global", "android")}
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "messages.remote_resource"], "jid_global", "android")}
GROUP BY messages._id
ORDER BY messages.timestamp ASC;"""
)
except sqlite3.OperationalError:
try:
c.execute("""SELECT jid_global.raw_string as key_remote_jid,
c.execute(f"""SELECT jid_global.raw_string as key_remote_jid,
message._id,
message.from_me as key_from_me,
message.timestamp,
@@ -244,7 +288,12 @@ def messages(db, data, media_folder):
message_system.action_type,
message_system_group.is_me_joined,
jid_old.raw_string as old_jid,
jid_new.raw_string as new_jid
jid_new.raw_string as new_jid,
jid_global.type as jid_type,
group_concat(receipt_user.receipt_timestamp) as receipt_timestamp,
group_concat(message.received_timestamp) as received_timestamp,
group_concat(receipt_user.read_timestamp) as read_timestamp,
group_concat(receipt_user.played_timestamp) as played_timestamp
FROM message
LEFT JOIN message_quoted
ON message_quoted.message_row_id = message._id
@@ -274,7 +323,14 @@ def messages(db, data, media_folder):
ON jid_old._id = message_system_number_change.old_jid_row_id
LEFT JOIN jid jid_new
ON jid_new._id = message_system_number_change.new_jid_row_id
WHERE key_remote_jid <> '-1';"""
LEFT JOIN receipt_user
ON receipt_user.message_row_id = message._id
WHERE key_remote_jid <> '-1'
{get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")}
{f'AND message.timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid_global", "android")}
{get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid_global", "android")}
GROUP BY message._id;"""
)
except Exception as e:
raise e
@@ -304,6 +360,8 @@ def messages(db, data, media_folder):
timestamp=content["timestamp"],
time=content["timestamp"],
key_id=content["key_id"],
timezone_offset=timezone_offset if timezone_offset else CURRENT_TZ_OFFSET,
message_type=content["media_wa_type"]
)
if isinstance(content["data"], bytes):
message.data = ("The message is binary data and its base64 is "
@@ -316,7 +374,7 @@ def messages(db, data, media_folder):
i += 1
content = c.fetchone()
continue
if "-" in content["key_remote_jid"] and content["key_from_me"] == 0:
if content["jid_type"] == JidType.GROUP and content["key_from_me"] == 0:
name = fallback = None
if table_message:
if content["sender_jid_row_id"] > 0:
@@ -390,8 +448,7 @@ def messages(db, data, media_folder):
message.data = None
else:
# Real message
if content["media_wa_type"] == 20: # Sticker is a message
message.sticker = True
message.sticker = content["media_wa_type"] == 20 # Sticker is a message
if content["key_from_me"] == 1:
if content["status"] == 5 and content["edit_version"] == 7 or table_message and content["media_wa_type"] == 15:
msg = "Message deleted"
@@ -404,9 +461,9 @@ def messages(db, data, media_folder):
msg = content["data"]
if msg is not None:
if "\r\n" in msg:
msg = msg.replace("\r\n", "<br>")
msg = msg.replace("\r\n", " <br>")
if "\n" in msg:
msg = msg.replace("\n", "<br>")
msg = msg.replace("\n", " <br>")
else:
if content["status"] == 0 and content["edit_version"] == 7 or table_message and content["media_wa_type"] == 15:
msg = "Message deleted"
@@ -419,9 +476,9 @@ def messages(db, data, media_folder):
msg = content["data"]
if msg is not None:
if "\r\n" in msg:
msg = msg.replace("\r\n", "<br>")
msg = msg.replace("\r\n", " <br>")
if "\n" in msg:
msg = msg.replace("\n", "<br>")
msg = msg.replace("\n", " <br>")
message.data = msg
data[content["key_remote_jid"]].add_message(content["_id"], message)
@@ -438,15 +495,44 @@ def messages(db, data, media_folder):
print(f"Processing messages...({total_row_number}/{total_row_number})", end="\r")
def media(db, data, media_folder):
def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separate_media=True):
# Get media
c = db.cursor()
c.execute("""SELECT count() FROM message_media""")
try:
c.execute(f"""SELECT count()
FROM message_media
INNER JOIN messages
ON message_media.message_row_id = messages._id
INNER JOIN jid
ON messages.key_remote_jid = jid.raw_string
LEFT JOIN chat
ON chat.jid_row_id = jid._id
WHERE 1=1
{get_cond_for_empty(filter_empty, "key_remote_jid", "messages.needs_push")}
{f'AND messages.timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}""")
except sqlite3.OperationalError:
c.execute(f"""SELECT count()
FROM message_media
INNER JOIN message
ON message_media.message_row_id = message._id
LEFT JOIN chat
ON chat._id = message.chat_row_id
INNER JOIN jid
ON jid._id = chat.jid_row_id
LEFT JOIN jid jid_group
ON jid_group._id = message.sender_jid_row_id
WHERE 1=1
{get_cond_for_empty(filter_empty, "jid.raw_string", "broadcast")}
{f'AND message.timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")}
{get_chat_condition(filter_chat[1], False, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")}""")
total_row_number = c.fetchone()[0]
print(f"\nProcessing media...(0/{total_row_number})", end="\r")
i = 0
try:
c.execute("""SELECT messages.key_remote_jid,
c.execute(f"""SELECT messages.key_remote_jid,
message_row_id,
file_path,
message_url,
@@ -459,10 +545,19 @@ def media(db, data, media_folder):
ON message_media.message_row_id = messages._id
LEFT JOIN media_hash_thumbnail
ON message_media.file_hash = media_hash_thumbnail.media_hash
INNER JOIN jid
ON messages.key_remote_jid = jid.raw_string
LEFT JOIN chat
ON chat.jid_row_id = jid._id
WHERE jid.type <> 7
{get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")}
{f'AND messages.timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
ORDER BY messages.key_remote_jid ASC"""
)
except sqlite3.OperationalError:
c.execute("""SELECT jid.raw_string as key_remote_jid,
c.execute(f"""SELECT jid.raw_string as key_remote_jid,
message_row_id,
file_path,
message_url,
@@ -471,14 +566,21 @@ def media(db, data, media_folder):
file_hash,
thumbnail
FROM message_media
INNER JOIN message
ON message_media.message_row_id = message._id
LEFT JOIN chat
ON chat._id = message.chat_row_id
INNER JOIN jid
ON jid._id = chat.jid_row_id
LEFT JOIN media_hash_thumbnail
INNER JOIN message
ON message_media.message_row_id = message._id
LEFT JOIN chat
ON chat._id = message.chat_row_id
INNER JOIN jid
ON jid._id = chat.jid_row_id
LEFT JOIN media_hash_thumbnail
ON message_media.file_hash = media_hash_thumbnail.media_hash
LEFT JOIN jid jid_group
ON jid_group._id = message.sender_jid_row_id
WHERE jid.type <> 7
{get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")}
{f'AND message.timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")}
{get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")}
ORDER BY jid.raw_string ASC"""
)
content = c.fetchone()
@@ -499,18 +601,16 @@ def media(db, data, media_folder):
message.mime = "application/octet-stream"
else:
message.mime = content["mime_type"]
if separate_media:
chat_display_name = slugify(data[content["key_remote_jid"]].name or message.sender \
or content["key_remote_jid"].split('@')[0], True)
current_filename = file_path.split("/")[-1]
new_folder = os.path.join(media_folder, "separated", chat_display_name)
Path(new_folder).mkdir(parents=True, exist_ok=True)
new_path = os.path.join(new_folder, current_filename)
shutil.copy2(file_path, new_path)
message.data = new_path
else:
if False: # Block execution
try:
r = requests.get(content["message_url"])
if r.status_code != 200:
raise RuntimeError()
except:
message.data = "The media is missing"
message.mime = "media"
message.meta = True
else:
...
message.data = "The media is missing"
message.mime = "media"
message.meta = True
@@ -528,20 +628,29 @@ def media(db, data, media_folder):
f"Processing media...({total_row_number}/{total_row_number})", end="\r")
def vcard(db, data):
def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
c = db.cursor()
try:
c.execute("""SELECT message_row_id,
c.execute(f"""SELECT message_row_id,
messages.key_remote_jid,
vcard,
messages.media_name
FROM messages_vcards
INNER JOIN messages
ON messages_vcards.message_row_id = messages._id
INNER JOIN jid
ON messages.key_remote_jid = jid.raw_string
LEFT JOIN chat
ON chat.jid_row_id = jid._id
WHERE 1=1
{get_cond_for_empty(filter_empty, "key_remote_jid", "messages.needs_push")}
{f'AND messages.timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
ORDER BY messages.key_remote_jid ASC;"""
)
except sqlite3.OperationalError:
c.execute("""SELECT message_row_id,
c.execute(f"""SELECT message_row_id,
jid.raw_string as key_remote_jid,
vcard,
message.text_data as media_name
@@ -552,40 +661,55 @@ def vcard(db, data):
ON chat._id = message.chat_row_id
INNER JOIN jid
ON jid._id = chat.jid_row_id
LEFT JOIN jid jid_group
ON jid_group._id = message.sender_jid_row_id
WHERE 1=1
{get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")}
{f'AND message.timestamp {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")}
{get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")}
ORDER BY message.chat_row_id ASC;"""
)
rows = c.fetchall()
total_row_number = len(rows)
print(f"\nProcessing vCards...(0/{total_row_number})", end="\r")
base = "WhatsApp/vCards"
if not os.path.isdir(base):
Path(base).mkdir(parents=True, exist_ok=True)
path = f"{media_folder}/vCards"
if not os.path.isdir(path):
Path(path).mkdir(parents=True, exist_ok=True)
for index, row in enumerate(rows):
media_name = row["media_name"] if row["media_name"] is not None else ""
media_name = row["media_name"] if row["media_name"] is not None else "Undefined vCard File"
file_name = "".join(x for x in media_name if x.isalnum())
file_name = file_name.encode('utf-8')[:230].decode('utf-8', 'ignore')
file_path = os.path.join(base, f"{file_name}.vcf")
file_path = os.path.join(path, f"{file_name}.vcf")
if not os.path.isfile(file_path):
with open(file_path, "w", encoding="utf-8") as f:
f.write(row["vcard"])
message = data[row["key_remote_jid"]].messages[row["message_row_id"]]
message.data = media_name + \
"The vCard file cannot be displayed here, " \
f"however it should be located at {file_path}"
message.data = "This media include the following vCard file(s):<br>" \
f'<a href="{htmle(file_path)}">{htmle(media_name)}</a>'
message.mime = "text/x-vcard"
message.meta = True
message.safe = True
print(f"Processing vCards...({index + 1}/{total_row_number})", end="\r")
def calls(db, data):
def calls(db, data, timezone_offset, filter_chat):
c = db.cursor()
c.execute("""SELECT count() FROM call_log""")
c.execute(f"""SELECT count()
FROM call_log
INNER JOIN jid
ON call_log.jid_row_id = jid._id
LEFT JOIN chat
ON call_log.jid_row_id = chat.jid_row_id
WHERE 1=1
{get_chat_condition(filter_chat[0], True, ["jid.raw_string"])}
{get_chat_condition(filter_chat[1], False, ["jid.raw_string"])}""")
total_row_number = c.fetchone()[0]
if total_row_number == 0:
return
print(f"\nProcessing calls...({total_row_number})", end="\r")
c.execute("""SELECT call_log._id,
c.execute(f"""SELECT call_log._id,
jid.raw_string,
from_me,
call_id,
@@ -593,10 +717,16 @@ def calls(db, data):
video_call,
duration,
call_result,
bytes_transferred
bytes_transferred,
chat.subject as chat_subject
FROM call_log
INNER JOIN jid
ON call_log.jid_row_id = jid._id"""
ON call_log.jid_row_id = jid._id
LEFT JOIN chat
ON call_log.jid_row_id = chat.jid_row_id
WHERE 1=1
{get_chat_condition(filter_chat[0], True, ["jid.raw_string"])}
{get_chat_condition(filter_chat[1], False, ["jid.raw_string"])}"""
)
chat = ChatStore(Device.ANDROID, "WhatsApp Calls")
content = c.fetchone()
@@ -606,28 +736,36 @@ def calls(db, data):
timestamp=content["timestamp"],
time=content["timestamp"],
key_id=content["call_id"],
timezone_offset=timezone_offset if timezone_offset else CURRENT_TZ_OFFSET
)
_jid = content["raw_string"]
if _jid in data:
name = data[_jid].name
fallback = _jid.split('@')[0] if "@" in _jid else None
call.sender = name or fallback
name = data[_jid].name if _jid in data else content["chat_subject"] or None
if _jid is not None and "@" in _jid:
fallback = _jid.split('@')[0]
else:
fallback = None
call.sender = name or fallback
call.meta = True
call.data = (
f"A {'video' if content['video_call'] else 'voice'} "
f"call {'to' if call.from_me else 'from'} "
f"{name or fallback} was "
f"{call.sender} was "
)
if content['call_result'] == 2:
if content['call_result'] in (0, 4, 7):
call.data += "cancelled." if call.from_me else "missed."
elif content['call_result'] == 2:
call.data += "not answered." if call.from_me else "missed."
elif content['call_result'] == 3:
call.data += "unavailable."
elif content['call_result'] == 5:
call_time = convert_time_unit(content['duration'])
call_bytes = bytes_to_readable(content['bytes_transferred'])
call.data += (
f"initiated and lasted for {content['duration']} second(s) "
f"with {content['bytes_transferred']} bytes transferred."
f"initiated and lasted for {call_time} "
f"with {call_bytes} data transferred."
)
else:
call.data += "in an unknown state."
chat.add_message(content["_id"], call)
content = c.fetchone()
data["000000000000000"] = chat
@@ -640,9 +778,11 @@ def create_html(
embedded=False,
offline_static=False,
maximum_size=None,
no_avatar=False
no_avatar=False,
experimental=False,
headline=None
):
template = setup_template(template, no_avatar)
template = setup_template(template, no_avatar, experimental)
total_row_number = len(data)
print(f"\nGenerating chats...(0/{total_row_number})", end="\r")
@@ -654,8 +794,6 @@ def create_html(
for current, contact in enumerate(data):
chat = data[contact]
if len(chat.messages) == 0:
continue
safe_file_name, name = get_file_name(contact, chat)
if maximum_size is not None:
@@ -679,13 +817,16 @@ def create_html(
render_box,
contact,
w3css,
f"{safe_file_name}-{current_page + 1}.html",
chat
chat,
headline,
next=f"{safe_file_name}-{current_page + 1}.html",
previous=f"{safe_file_name}-{current_page - 1}.html" if current_page > 1 else False
)
render_box = [message]
current_size = 0
current_page += 1
else:
render_box.append(message)
if message.key_id == last_msg:
if current_page == 1:
output_file_name = f"{output_folder}/{safe_file_name}.html"
@@ -698,11 +839,11 @@ def create_html(
render_box,
contact,
w3css,
chat,
headline,
False,
chat
previous=f"{safe_file_name}-{current_page - 1}.html"
)
else:
render_box.append(message)
else:
output_file_name = f"{output_folder}/{safe_file_name}.html"
rendering(
@@ -712,10 +853,46 @@ def create_html(
chat.get_messages(),
contact,
w3css,
False,
chat
chat,
headline,
False
)
if current % 10 == 0:
print(f"Generating chats...({current}/{total_row_number})", end="\r")
print(f"Generating chats...({total_row_number}/{total_row_number})", end="\r")
def create_txt(data, output):
os.makedirs(output, exist_ok=True)
for jik, chat in data.items():
if chat.name is not None:
contact = chat.name.replace('/', '')
else:
contact = jik.replace('+', '')
output_file = os.path.join(output, f"{contact}.txt")
with open(output_file, "w", encoding="utf8") as f:
for message in chat.messages.values():
date = datetime.fromtimestamp(message.timestamp).date()
if message.meta and message.mime != "media":
continue # Skip any metadata in text format
if message.from_me:
name = "You"
else:
name = message.sender if message.sender else contact
prefix = f"[{date} {message.time}] {name}: "
prefix_length = len(prefix)
if message.media and ("/" in message.mime or message.mime == "media"):
if message.data == "The media is missing":
message_text = "<The media is missing>"
else:
message_text = f"<media file in {message.data}>"
else:
if message.data is None:
message_text = ""
else:
message_text = message.data.replace('<br>', f'\n{" " * prefix_length}')
if message.caption is not None:
message_text += "\n" + ' ' * len(prefix) + message.caption.replace('<br>', f'\n{" " * prefix_length}')
f.write(f"{prefix}{message_text}\n")

View File

@@ -0,0 +1,292 @@
#################################################################################
# Copyright (C) 2009-2011 Vladimir "Farcaller" Pouzanov <farcaller@gmail.com> #
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy #
# of this software and associated documentation files (the "Software"), to deal #
# in the Software without restriction, including without limitation the rights #
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell #
# copies of the Software, and to permit persons to whom the Software is #
# furnished to do so, subject to the following conditions: #
# #
# The above copyright notice and this permission notice shall be included in #
# all copies or substantial portions of the Software. #
# #
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR #
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, #
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE #
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER #
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, #
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN #
# THE SOFTWARE. #
#################################################################################
import struct
import codecs
from datetime import datetime, timedelta
class BPListWriter(object):
def __init__(self, objects):
self.bplist = ""
self.objects = objects
def binary(self):
'''binary -> string
Generates bplist
'''
self.data = 'bplist00'
# TODO: flatten objects and count max length size
# TODO: write objects and save offsets
# TODO: write offsets
# TODO: write metadata
return self.data
def write(self, filename):
'''
Writes bplist to file
'''
if self.bplist != "":
pass
# TODO: save self.bplist to file
else:
raise Exception('BPlist not yet generated')
class BPListReader(object):
def __init__(self, s):
self.data = s
self.objects = []
self.resolved = {}
def __unpackIntStruct(self, sz, s):
'''__unpackIntStruct(size, string) -> int
Unpacks the integer of given size (1, 2 or 4 bytes) from string
'''
if sz == 1:
ot = '!B'
elif sz == 2:
ot = '!H'
elif sz == 4:
ot = '!I'
elif sz == 8:
ot = '!Q'
else:
raise Exception('int unpack size '+str(sz)+' unsupported')
return struct.unpack(ot, s)[0]
def __unpackInt(self, offset):
'''__unpackInt(offset) -> int
Unpacks int field from plist at given offset
'''
return self.__unpackIntMeta(offset)[1]
def __unpackIntMeta(self, offset):
'''__unpackIntMeta(offset) -> (size, int)
Unpacks int field from plist at given offset and returns its size and value
'''
obj_header = self.data[offset]
obj_type, obj_info = (obj_header & 0xF0), (obj_header & 0x0F)
int_sz = 2**obj_info
return int_sz, self.__unpackIntStruct(int_sz, self.data[offset+1:offset+1+int_sz])
def __resolveIntSize(self, obj_info, offset):
'''__resolveIntSize(obj_info, offset) -> (count, offset)
Calculates count of objref* array entries and returns count and offset to first element
'''
if obj_info == 0x0F:
ofs, obj_count = self.__unpackIntMeta(offset+1)
objref = offset+2+ofs
else:
obj_count = obj_info
objref = offset+1
return obj_count, objref
def __unpackFloatStruct(self, sz, s):
'''__unpackFloatStruct(size, string) -> float
Unpacks the float of given size (4 or 8 bytes) from string
'''
if sz == 4:
ot = '!f'
elif sz == 8:
ot = '!d'
else:
raise Exception('float unpack size '+str(sz)+' unsupported')
return struct.unpack(ot, s)[0]
def __unpackFloat(self, offset):
'''__unpackFloat(offset) -> float
Unpacks float field from plist at given offset
'''
obj_header = self.data[offset]
obj_type, obj_info = (obj_header & 0xF0), (obj_header & 0x0F)
int_sz = 2**obj_info
return int_sz, self.__unpackFloatStruct(int_sz, self.data[offset+1:offset+1+int_sz])
def __unpackDate(self, offset):
td = int(struct.unpack(">d", self.data[offset+1:offset+9])[0])
return datetime(year=2001,month=1,day=1) + timedelta(seconds=td)
def __unpackItem(self, offset):
'''__unpackItem(offset)
Unpacks and returns an item from plist
'''
obj_header = self.data[offset]
obj_type, obj_info = (obj_header & 0xF0), (obj_header & 0x0F)
if obj_type == 0x00:
if obj_info == 0x00: # null 0000 0000
return None
elif obj_info == 0x08: # bool 0000 1000 // false
return False
elif obj_info == 0x09: # bool 0000 1001 // true
return True
elif obj_info == 0x0F: # fill 0000 1111 // fill byte
raise Exception("0x0F Not Implemented") # this is really pad byte, FIXME
else:
raise Exception('unpack item type '+str(obj_header)+' at '+str(offset)+ 'failed')
elif obj_type == 0x10: # int 0001 nnnn ... // # of bytes is 2^nnnn, big-endian bytes
return self.__unpackInt(offset)
elif obj_type == 0x20: # real 0010 nnnn ... // # of bytes is 2^nnnn, big-endian bytes
return self.__unpackFloat(offset)
elif obj_type == 0x30: # date 0011 0011 ... // 8 byte float follows, big-endian bytes
return self.__unpackDate(offset)
elif obj_type == 0x40: # data 0100 nnnn [int] ... // nnnn is number of bytes unless 1111 then int count follows, followed by bytes
obj_count, objref = self.__resolveIntSize(obj_info, offset)
return self.data[objref:objref+obj_count] # XXX: we return data as str
elif obj_type == 0x50: # string 0101 nnnn [int] ... // ASCII string, nnnn is # of chars, else 1111 then int count, then bytes
obj_count, objref = self.__resolveIntSize(obj_info, offset)
return self.data[objref:objref+obj_count]
elif obj_type == 0x60: # string 0110 nnnn [int] ... // Unicode string, nnnn is # of chars, else 1111 then int count, then big-endian 2-byte uint16_t
obj_count, objref = self.__resolveIntSize(obj_info, offset)
return self.data[objref:objref+obj_count*2].decode('utf-16be')
elif obj_type == 0x80: # uid 1000 nnnn ... // nnnn+1 is # of bytes
# FIXME: Accept as a string for now
obj_count, objref = self.__resolveIntSize(obj_info, offset)
return self.data[objref:objref+obj_count]
elif obj_type == 0xA0: # array 1010 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
obj_count, objref = self.__resolveIntSize(obj_info, offset)
arr = []
for i in range(obj_count):
arr.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
return arr
elif obj_type == 0xC0: # set 1100 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
# XXX: not serializable via apple implementation
raise Exception("0xC0 Not Implemented") # FIXME: implement
elif obj_type == 0xD0: # dict 1101 nnnn [int] keyref* objref* // nnnn is count, unless '1111', then int count follows
obj_count, objref = self.__resolveIntSize(obj_info, offset)
keys = []
for i in range(obj_count):
keys.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
values = []
objref += obj_count*self.object_ref_size
for i in range(obj_count):
values.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
dic = {}
for i in range(obj_count):
dic[keys[i]] = values[i]
return dic
else:
raise Exception('don\'t know how to unpack obj type '+hex(obj_type)+' at '+str(offset))
def __resolveObject(self, idx):
try:
return self.resolved[idx]
except KeyError:
obj = self.objects[idx]
if type(obj) == list:
newArr = []
for i in obj:
newArr.append(self.__resolveObject(i))
self.resolved[idx] = newArr
return newArr
if type(obj) == dict:
newDic = {}
for k,v in obj.items():
key_resolved = self.__resolveObject(k)
if isinstance(key_resolved, str):
rk = key_resolved
else:
rk = codecs.decode(key_resolved, "utf-8")
rv = self.__resolveObject(v)
newDic[rk] = rv
self.resolved[idx] = newDic
return newDic
else:
self.resolved[idx] = obj
return obj
def parse(self):
# read header
if self.data[:8] != b'bplist00':
raise Exception('Bad magic')
# read trailer
self.offset_size, self.object_ref_size, self.number_of_objects, self.top_object, self.table_offset = struct.unpack('!6xBB4xI4xI4xI', self.data[-32:])
#print "** plist offset_size:",self.offset_size,"objref_size:",self.object_ref_size,"num_objs:",self.number_of_objects,"top:",self.top_object,"table_ofs:",self.table_offset
# read offset table
self.offset_table = self.data[self.table_offset:-32]
self.offsets = []
ot = self.offset_table
for i in range(self.number_of_objects):
offset_entry = ot[:self.offset_size]
ot = ot[self.offset_size:]
self.offsets.append(self.__unpackIntStruct(self.offset_size, offset_entry))
#print "** plist offsets:",self.offsets
# read object table
self.objects = []
k = 0
for i in self.offsets:
obj = self.__unpackItem(i)
#print "** plist unpacked",k,type(obj),obj,"at",i
k += 1
self.objects.append(obj)
# rebuild object tree
#for i in range(len(self.objects)):
# self.__resolveObject(i)
# return root object
return self.__resolveObject(self.top_object)
@classmethod
def plistWithString(cls, s):
parser = cls(s)
return parser.parse()
# helpers for testing
def plist(obj):
from Foundation import NSPropertyListSerialization, NSPropertyListBinaryFormat_v1_0
b = NSPropertyListSerialization.dataWithPropertyList_format_options_error_(obj, NSPropertyListBinaryFormat_v1_0, 0, None)
return str(b.bytes())
def unplist(s):
from Foundation import NSData, NSPropertyListSerialization
d = NSData.dataWithBytes_length_(s, len(s))
return NSPropertyListSerialization.propertyListWithData_options_format_error_(d, 0, None, None)
if __name__ == "__main__":
import os
import sys
import json
file_path = sys.argv[1]
with open(file_path, "rb") as fp:
data = fp.read()
out = BPListReader(data).parse()
with open(file_path + ".json", "w") as fp:
json.dump(out, indent=4)

View File

@@ -1,10 +1,19 @@
#!/usr/bin/python3
import os
from datetime import datetime
from datetime import datetime, tzinfo, timedelta
from typing import Union
class TimeZone(tzinfo):
def __init__(self, offset):
self.offset = offset
def utcoffset(self, dt):
return timedelta(hours=self.offset)
def dst(self, dt):
return timedelta(0)
class ChatStore():
def __init__(self, type, name=None, media=None):
if name is not None and not isinstance(name, str):
@@ -25,6 +34,7 @@ class ChatStore():
self.their_avatar = None
self.their_avatar_thumb = None
self.status = None
self.media_base = ""
def add_message(self, id, message):
if not isinstance(message, Message):
@@ -55,15 +65,15 @@ class ChatStore():
class Message():
def __init__(self, from_me: Union[bool,int], timestamp: int, time: Union[int,float,str], key_id: int):
def __init__(self, from_me: Union[bool,int], timestamp: int, time: Union[int,float,str], key_id: int, timezone_offset: int = 0, message_type: int = None):
self.from_me = bool(from_me)
self.timestamp = timestamp / 1000 if timestamp > 9999999999 else timestamp
if isinstance(time, int) or isinstance(time, float):
self.time = datetime.fromtimestamp(time/1000).strftime("%H:%M")
self.time = datetime.fromtimestamp(self.timestamp, TimeZone(timezone_offset)).strftime("%H:%M")
elif isinstance(time, str):
self.time = time
else:
raise TypeError("Time must be a string or integer")
raise TypeError("Time must be a string or number")
self.media = False
self.key_id = key_id
self.meta = False
@@ -71,6 +81,7 @@ class Message():
self.sender = None
self.safe = False
self.mime = None
self.message_type = message_type
# Extra
self.reply = None
self.quoted_data = None

View File

@@ -1,280 +0,0 @@
#!/usr/bin/python3
import os
from glob import glob
from pathlib import Path
from mimetypes import MimeTypes
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, Device
def contacts(db, data):
c = db.cursor()
# Get status only lol
c.execute("""SELECT count() FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
total_row_number = c.fetchone()[0]
print(f"Pre-processing contacts...({total_row_number})")
c.execute("""SELECT ZWHATSAPPID, ZABOUTTEXT FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
content = c.fetchone()
while content is not None:
if not content["ZWHATSAPPID"].endswith("@s.whatsapp.net"):
_id = content["ZWHATSAPPID"] + "@s.whatsapp.net"
data[_id] = ChatStore(Device.IOS)
data[_id].status = content["ZABOUTTEXT"]
content = c.fetchone()
def messages(db, data, media_folder):
c = db.cursor()
# Get contacts
c.execute("""SELECT count() FROM ZWACHATSESSION""")
total_row_number = c.fetchone()[0]
print(f"Processing contacts...({total_row_number})")
c.execute(
"""SELECT ZCONTACTJID,
ZPARTNERNAME,
ZPUSHNAME
FROM ZWACHATSESSION
LEFT JOIN ZWAPROFILEPUSHNAME
ON ZWACHATSESSION.ZCONTACTJID = ZWAPROFILEPUSHNAME.ZJID;"""
)
content = c.fetchone()
while content is not None:
is_phone = content["ZPARTNERNAME"].replace("+", "").replace(" ", "").isdigit()
if content["ZPUSHNAME"] is None or (content["ZPUSHNAME"] and not is_phone):
contact_name = content["ZPARTNERNAME"]
else:
contact_name = content["ZPUSHNAME"]
contact_id = content["ZCONTACTJID"]
if contact_id not in data:
data[contact_id] = ChatStore(Device.IOS, contact_name, media_folder)
else:
data[contact_id].name = contact_name
data[contact_id].my_avatar = os.path.join(media_folder, "Media/Profile/Photo.jpg")
path = f'{media_folder}/Media/Profile/{contact_id.split("@")[0]}'
avatars = glob(f"{path}*")
if 0 < len(avatars) <= 1:
data[contact_id].their_avatar = avatars[0]
else:
for avatar in avatars:
if avatar.endswith(".thumb") and data[content["ZCONTACTJID"]].their_avatar_thumb is None:
data[contact_id].their_avatar_thumb = avatar
elif avatar.endswith(".jpg") and data[content["ZCONTACTJID"]].their_avatar is None:
data[contact_id].their_avatar = avatar
content = c.fetchone()
# Get message history
c.execute("""SELECT count() FROM ZWAMESSAGE""")
total_row_number = c.fetchone()[0]
print(f"Processing messages...(0/{total_row_number})", end="\r")
c.execute("""SELECT COALESCE(ZFROMJID, ZTOJID) as _id,
ZWAMESSAGE.Z_PK,
ZISFROMME,
ZMESSAGEDATE,
ZTEXT,
ZMESSAGETYPE,
ZWAGROUPMEMBER.ZMEMBERJID,
ZMETADATA,
ZSTANZAID
FROM ZWAMESSAGE
LEFT JOIN ZWAGROUPMEMBER
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
LEFT JOIN ZWAMEDIAITEM
ON ZWAMESSAGE.Z_PK = ZWAMEDIAITEM.ZMESSAGE;""")
i = 0
content = c.fetchone()
while content is not None:
_id = content["_id"]
Z_PK = content["Z_PK"]
if _id not in data:
data[_id] = ChatStore(Device.IOS)
path = f'{media_folder}/Media/Profile/{_id.split("@")[0]}'
avatars = glob(f"{path}*")
if 0 < len(avatars) <= 1:
data[_id].their_avatar = avatars[0]
else:
for avatar in avatars:
if avatar.endswith(".thumb"):
data[_id].their_avatar_thumb = avatar
elif avatar.endswith(".jpg"):
data[_id].their_avatar = avatar
ts = APPLE_TIME + content["ZMESSAGEDATE"]
message = Message(
from_me=content["ZISFROMME"],
timestamp=ts,
time=ts, # TODO: Could be bug
key_id=content["ZSTANZAID"][:17],
)
invalid = False
if "-" in _id and content["ZISFROMME"] == 0:
name = None
if content["ZMEMBERJID"] is not None:
if content["ZMEMBERJID"] in data:
name = data[content["ZMEMBERJID"]].name
if "@" in content["ZMEMBERJID"]:
fallback = content["ZMEMBERJID"].split('@')[0]
else:
fallback = None
else:
fallback = None
message.sender = name or fallback
else:
message.sender = None
if content["ZMESSAGETYPE"] == 6:
# Metadata
if "-" in _id:
# Group
if content["ZTEXT"] is not None:
# Chnaged name
try:
int(content["ZTEXT"])
except ValueError:
msg = f"The group name changed to {content['ZTEXT']}"
message.data = msg
message.meta = True
else:
invalid = True
else:
message.data = None
else:
message.data = None
else:
# real message
if content["ZMETADATA"] is not None and content["ZMETADATA"].startswith(b"\x2a\x14"):
quoted = content["ZMETADATA"][2:19]
message.reply = quoted.decode()
message.quoted_data = None # TODO
if content["ZMESSAGETYPE"] == 15: # Sticker
message.sticker = True
if content["ZISFROMME"] == 1:
if content["ZMESSAGETYPE"] == 14:
msg = "Message deleted"
message.meta = True
else:
msg = content["ZTEXT"]
if msg is not None:
if "\r\n" in msg:
msg = msg.replace("\r\n", "<br>")
if "\n" in msg:
msg = msg.replace("\n", "<br>")
else:
if content["ZMESSAGETYPE"] == 14:
msg = "Message deleted"
message.meta = True
else:
msg = content["ZTEXT"]
if msg is not None:
if "\r\n" in msg:
msg = msg.replace("\r\n", "<br>")
if "\n" in msg:
msg = msg.replace("\n", "<br>")
message.data = msg
if not invalid:
data[_id].add_message(Z_PK, message)
i += 1
if i % 1000 == 0:
print(f"Processing messages...({i}/{total_row_number})", end="\r")
content = c.fetchone()
print(
f"Processing messages...({total_row_number}/{total_row_number})", end="\r")
def media(db, data, media_folder):
c = db.cursor()
# Get media
c.execute("""SELECT count() FROM ZWAMEDIAITEM""")
total_row_number = c.fetchone()[0]
print(f"\nProcessing media...(0/{total_row_number})", end="\r")
i = 0
c.execute("""SELECT COALESCE(ZWAMESSAGE.ZFROMJID, ZWAMESSAGE.ZTOJID) as _id,
ZMESSAGE,
ZMEDIALOCALPATH,
ZMEDIAURL,
ZVCARDSTRING,
ZMEDIAKEY,
ZTITLE
FROM ZWAMEDIAITEM
INNER JOIN ZWAMESSAGE
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK
WHERE ZMEDIALOCALPATH IS NOT NULL
ORDER BY _id ASC""")
content = c.fetchone()
mime = MimeTypes()
while content is not None:
file_path = f"{media_folder}/Message/{content['ZMEDIALOCALPATH']}"
_id = content["_id"]
ZMESSAGE = content["ZMESSAGE"]
message = data[_id].messages[ZMESSAGE]
message.media = True
if os.path.isfile(file_path):
message.data = file_path
if content["ZVCARDSTRING"] is None:
guess = mime.guess_type(file_path)[0]
if guess is not None:
message.mime = guess
else:
message.mime = "application/octet-stream"
else:
message.mime = content["ZVCARDSTRING"]
else:
if False: # Block execution
try:
r = requests.get(content["ZMEDIAURL"])
if r.status_code != 200:
raise RuntimeError()
except:
message.data = "The media is missing"
message.mime = "media"
message.meta = True
else:
...
message.data = "The media is missing"
message.mime = "media"
message.meta = True
if content["ZTITLE"] is not None:
message.caption = content["ZTITLE"]
i += 1
if i % 100 == 0:
print(f"Processing media...({i}/{total_row_number})", end="\r")
content = c.fetchone()
print(
f"Processing media...({total_row_number}/{total_row_number})", end="\r")
def vcard(db, data):
c = db.cursor()
c.execute("""SELECT DISTINCT ZWAVCARDMENTION.ZMEDIAITEM,
ZWAMEDIAITEM.ZMESSAGE,
COALESCE(ZWAMESSAGE.ZFROMJID,
ZWAMESSAGE.ZTOJID) as _id,
ZVCARDNAME,
ZVCARDSTRING
FROM ZWAVCARDMENTION
INNER JOIN ZWAMEDIAITEM
ON ZWAVCARDMENTION.ZMEDIAITEM = ZWAMEDIAITEM.Z_PK
INNER JOIN ZWAMESSAGE
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK""")
contents = c.fetchall()
total_row_number = len(contents)
print(f"\nProcessing vCards...(0/{total_row_number})", end="\r")
base = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared/Message/vCards"
if not os.path.isdir(base):
Path(base).mkdir(parents=True, exist_ok=True)
for index, content in enumerate(contents):
file_name = "".join(x for x in content["ZVCARDNAME"] if x.isalnum())
file_name = file_name.encode('utf-8')[:230].decode('utf-8', 'ignore')
file_path = os.path.join(base, f"{file_name}.vcf")
if not os.path.isfile(file_path):
with open(file_path, "w", encoding="utf-8") as f:
f.write(content["ZVCARDSTRING"])
message = data[content["_id"]].messages[content["ZMESSAGE"]]
message.data = content["ZVCARDNAME"] + \
"The vCard file cannot be displayed here, " \
f"however it should be located at {file_path}"
message.mime = "text/x-vcard"
message.media = True
message.meta = True
print(f"Processing vCards...({index + 1}/{total_row_number})", end="\r")

View File

@@ -0,0 +1,443 @@
#!/usr/bin/python3
import os
import shutil
from glob import glob
from pathlib import Path
from mimetypes import MimeTypes
from markupsafe import escape as htmle
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, CURRENT_TZ_OFFSET, get_chat_condition
from Whatsapp_Chat_Exporter.utility import bytes_to_readable, convert_time_unit, slugify, Device
def contacts(db, data):
c = db.cursor()
# Get status only lol
c.execute("""SELECT count() FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
total_row_number = c.fetchone()[0]
print(f"Pre-processing contacts...({total_row_number})")
c.execute("""SELECT ZWHATSAPPID, ZABOUTTEXT FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
content = c.fetchone()
while content is not None:
if not content["ZWHATSAPPID"].endswith("@s.whatsapp.net"):
ZWHATSAPPID = content["ZWHATSAPPID"] + "@s.whatsapp.net"
data[ZWHATSAPPID] = ChatStore(Device.IOS)
data[ZWHATSAPPID].status = content["ZABOUTTEXT"]
content = c.fetchone()
def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat, filter_empty):
c = db.cursor()
cursor2 = db.cursor()
# Get contacts
c.execute(
f"""SELECT count()
FROM (SELECT DISTINCT ZCONTACTJID,
ZPARTNERNAME,
ZWAPROFILEPUSHNAME.ZPUSHNAME
FROM ZWACHATSESSION
INNER JOIN ZWAMESSAGE
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
LEFT JOIN ZWAPROFILEPUSHNAME
ON ZWACHATSESSION.ZCONTACTJID = ZWAPROFILEPUSHNAME.ZJID
LEFT JOIN ZWAGROUPMEMBER
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
WHERE 1=1
{get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
{get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
GROUP BY ZCONTACTJID);"""
)
total_row_number = c.fetchone()[0]
print(f"Processing contacts...({total_row_number})")
c.execute(
f"""SELECT DISTINCT ZCONTACTJID,
ZPARTNERNAME,
ZWAPROFILEPUSHNAME.ZPUSHNAME
FROM ZWACHATSESSION
INNER JOIN ZWAMESSAGE
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
LEFT JOIN ZWAPROFILEPUSHNAME
ON ZWACHATSESSION.ZCONTACTJID = ZWAPROFILEPUSHNAME.ZJID
LEFT JOIN ZWAGROUPMEMBER
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
WHERE 1=1
{get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
{get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
GROUP BY ZCONTACTJID;"""
)
content = c.fetchone()
while content is not None:
is_phone = content["ZPARTNERNAME"].replace("+", "").replace(" ", "").isdigit()
if content["ZPUSHNAME"] is None or (content["ZPUSHNAME"] and not is_phone):
contact_name = content["ZPARTNERNAME"]
else:
contact_name = content["ZPUSHNAME"]
contact_id = content["ZCONTACTJID"]
if contact_id not in data:
data[contact_id] = ChatStore(Device.IOS, contact_name, media_folder)
else:
data[contact_id].name = contact_name
data[contact_id].my_avatar = os.path.join(media_folder, "Media/Profile/Photo.jpg")
path = f'{media_folder}/Media/Profile/{contact_id.split("@")[0]}'
avatars = glob(f"{path}*")
if 0 < len(avatars) <= 1:
data[contact_id].their_avatar = avatars[0]
else:
for avatar in avatars:
if avatar.endswith(".thumb") and data[content["ZCONTACTJID"]].their_avatar_thumb is None:
data[contact_id].their_avatar_thumb = avatar
elif avatar.endswith(".jpg") and data[content["ZCONTACTJID"]].their_avatar is None:
data[contact_id].their_avatar = avatar
content = c.fetchone()
# Get message history
c.execute(f"""SELECT count()
FROM ZWAMESSAGE
INNER JOIN ZWACHATSESSION
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
LEFT JOIN ZWAGROUPMEMBER
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
WHERE 1=1
{f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
{get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}""")
total_row_number = c.fetchone()[0]
print(f"Processing messages...(0/{total_row_number})", end="\r")
c.execute(f"""SELECT ZCONTACTJID,
ZWAMESSAGE.Z_PK,
ZISFROMME,
ZMESSAGEDATE,
ZTEXT,
ZMESSAGETYPE,
ZWAGROUPMEMBER.ZMEMBERJID,
ZMETADATA,
ZSTANZAID,
ZGROUPINFO
FROM ZWAMESSAGE
LEFT JOIN ZWAGROUPMEMBER
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
LEFT JOIN ZWAMEDIAITEM
ON ZWAMESSAGE.Z_PK = ZWAMEDIAITEM.ZMESSAGE
INNER JOIN ZWACHATSESSION
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
WHERE 1=1
{f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
{get_chat_condition(filter_chat[1], False, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
ORDER BY ZMESSAGEDATE ASC;""")
i = 0
content = c.fetchone()
while content is not None:
ZCONTACTJID = content["ZCONTACTJID"]
Z_PK = content["Z_PK"]
is_group_message = content["ZGROUPINFO"] is not None
if ZCONTACTJID not in data:
data[ZCONTACTJID] = ChatStore(Device.IOS)
path = f'{media_folder}/Media/Profile/{ZCONTACTJID.split("@")[0]}'
avatars = glob(f"{path}*")
if 0 < len(avatars) <= 1:
data[ZCONTACTJID].their_avatar = avatars[0]
else:
for avatar in avatars:
if avatar.endswith(".thumb"):
data[ZCONTACTJID].their_avatar_thumb = avatar
elif avatar.endswith(".jpg"):
data[ZCONTACTJID].their_avatar = avatar
ts = APPLE_TIME + content["ZMESSAGEDATE"]
message = Message(
from_me=content["ZISFROMME"],
timestamp=ts,
time=ts, # TODO: Could be bug
key_id=content["ZSTANZAID"][:17],
timezone_offset=timezone_offset if timezone_offset else CURRENT_TZ_OFFSET,
message_type=content["ZMESSAGETYPE"]
)
invalid = False
if is_group_message and content["ZISFROMME"] == 0:
name = None
if content["ZMEMBERJID"] is not None:
if content["ZMEMBERJID"] in data:
name = data[content["ZMEMBERJID"]].name
if "@" in content["ZMEMBERJID"]:
fallback = content["ZMEMBERJID"].split('@')[0]
else:
fallback = None
else:
fallback = None
message.sender = name or fallback
else:
message.sender = None
if content["ZMESSAGETYPE"] == 6:
# Metadata
if is_group_message:
# Group
if content["ZTEXT"] is not None:
# Chnaged name
try:
int(content["ZTEXT"])
except ValueError:
msg = f"The group name changed to {content['ZTEXT']}"
message.data = msg
message.meta = True
else:
invalid = True
else:
message.data = None
else:
message.data = None
else:
# real message
if content["ZMETADATA"] is not None and content["ZMETADATA"].startswith(b"\x2a\x14"):
quoted = content["ZMETADATA"][2:19]
message.reply = quoted.decode()
cursor2.execute(f"""SELECT ZTEXT
FROM ZWAMESSAGE
WHERE ZSTANZAID LIKE '{message.reply}%'""")
quoted_content = cursor2.fetchone()
if quoted_content and "ZTEXT" in quoted_content:
message.quoted_data = quoted_content["ZTEXT"]
else:
message.quoted_data = None
if content["ZMESSAGETYPE"] == 15: # Sticker
message.sticker = True
if content["ZISFROMME"] == 1:
if content["ZMESSAGETYPE"] == 14:
msg = "Message deleted"
message.meta = True
else:
msg = content["ZTEXT"]
if msg is not None:
if "\r\n" in msg:
msg = msg.replace("\r\n", "<br>")
if "\n" in msg:
msg = msg.replace("\n", "<br>")
else:
if content["ZMESSAGETYPE"] == 14:
msg = "Message deleted"
message.meta = True
else:
msg = content["ZTEXT"]
if msg is not None:
if "\r\n" in msg:
msg = msg.replace("\r\n", "<br>")
if "\n" in msg:
msg = msg.replace("\n", "<br>")
message.data = msg
if not invalid:
data[ZCONTACTJID].add_message(Z_PK, message)
i += 1
if i % 1000 == 0:
print(f"Processing messages...({i}/{total_row_number})", end="\r")
content = c.fetchone()
print(
f"Processing messages...({total_row_number}/{total_row_number})", end="\r")
def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separate_media=False):
c = db.cursor()
# Get media
c.execute(f"""SELECT count()
FROM ZWAMEDIAITEM
INNER JOIN ZWAMESSAGE
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK
INNER JOIN ZWACHATSESSION
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
LEFT JOIN ZWAGROUPMEMBER
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
WHERE 1=1
{f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID","ZMEMBERJID"], "ZGROUPINFO", "ios")}
{get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
""")
total_row_number = c.fetchone()[0]
print(f"\nProcessing media...(0/{total_row_number})", end="\r")
i = 0
c.execute(f"""SELECT ZCONTACTJID,
ZMESSAGE,
ZMEDIALOCALPATH,
ZMEDIAURL,
ZVCARDSTRING,
ZMEDIAKEY,
ZTITLE
FROM ZWAMEDIAITEM
INNER JOIN ZWAMESSAGE
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK
INNER JOIN ZWACHATSESSION
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
LEFT JOIN ZWAGROUPMEMBER
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
WHERE ZMEDIALOCALPATH IS NOT NULL
{f'AND ZWAMESSAGE.ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
{get_chat_condition(filter_chat[1], False, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
ORDER BY ZCONTACTJID ASC""")
content = c.fetchone()
mime = MimeTypes()
while content is not None:
file_path = f"{media_folder}/Message/{content['ZMEDIALOCALPATH']}"
ZMESSAGE = content["ZMESSAGE"]
contact = data[content["ZCONTACTJID"]]
message = contact.messages[ZMESSAGE]
message.media = True
if contact.media_base == "":
contact.media_base = media_folder + "/"
if os.path.isfile(file_path):
message.data = '/'.join(file_path.split("/")[1:])
if content["ZVCARDSTRING"] is None:
guess = mime.guess_type(file_path)[0]
if guess is not None:
message.mime = guess
else:
message.mime = "application/octet-stream"
else:
message.mime = content["ZVCARDSTRING"]
if separate_media:
chat_display_name = slugify(contact.name or message.sender \
or content["ZCONTACTJID"].split('@')[0], True)
current_filename = file_path.split("/")[-1]
new_folder = os.path.join(media_folder, "separated", chat_display_name)
Path(new_folder).mkdir(parents=True, exist_ok=True)
new_path = os.path.join(new_folder, current_filename)
shutil.copy2(file_path, new_path)
message.data = '/'.join(new_path.split("\\")[1:])
else:
message.data = "The media is missing"
message.mime = "media"
message.meta = True
if content["ZTITLE"] is not None:
message.caption = content["ZTITLE"]
i += 1
if i % 100 == 0:
print(f"Processing media...({i}/{total_row_number})", end="\r")
content = c.fetchone()
print(
f"Processing media...({total_row_number}/{total_row_number})", end="\r")
def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
c = db.cursor()
c.execute(f"""SELECT DISTINCT ZWAVCARDMENTION.ZMEDIAITEM,
ZWAMEDIAITEM.ZMESSAGE,
ZCONTACTJID,
ZVCARDNAME,
ZVCARDSTRING
FROM ZWAVCARDMENTION
INNER JOIN ZWAMEDIAITEM
ON ZWAVCARDMENTION.ZMEDIAITEM = ZWAMEDIAITEM.Z_PK
INNER JOIN ZWAMESSAGE
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK
INNER JOIN ZWACHATSESSION
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
LEFT JOIN ZWAGROUPMEMBER
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
WHERE 1=1
{f'AND ZWAMESSAGE.ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
{get_chat_condition(filter_chat[0], True, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
{get_chat_condition(filter_chat[1], False, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")};""")
contents = c.fetchall()
total_row_number = len(contents)
print(f"\nProcessing vCards...(0/{total_row_number})", end="\r")
path = f'{media_folder}/Message/vCards'
Path(path).mkdir(parents=True, exist_ok=True)
for index, content in enumerate(contents):
file_paths = []
vcard_names = content["ZVCARDNAME"].split("_$!<Name-Separator>!$_")
vcard_strings = content["ZVCARDSTRING"].split("_$!<VCard-Separator>!$_")
# If this is a list of contacts
if len(vcard_names) > len(vcard_strings):
vcard_names.pop(0) # Dismiss the first element, which is the group name
for name, vcard_string in zip(vcard_names, vcard_strings):
file_name = "".join(x for x in name if x.isalnum())
file_name = file_name.encode('utf-8')[:230].decode('utf-8', 'ignore')
file_path = os.path.join(path, f"{file_name}.vcf")
file_paths.append(file_path)
if not os.path.isfile(file_path):
with open(file_path, "w", encoding="utf-8") as f:
f.write(vcard_string)
vcard_summary = "This media include the following vCard file(s):<br>"
vcard_summary += " | ".join([f'<a href="{htmle(fp)}">{htmle(name)}</a>' for name, fp in zip(vcard_names, file_paths)])
message = data[content["ZCONTACTJID"]].messages[content["ZMESSAGE"]]
message.data = vcard_summary
message.mime = "text/x-vcard"
message.media = True
message.meta = True
message.safe = True
print(f"Processing vCards...({index + 1}/{total_row_number})", end="\r")
def calls(db, data, timezone_offset, filter_chat):
c = db.cursor()
c.execute(f"""SELECT count()
FROM ZWACDCALLEVENT
WHERE 1=1
{get_chat_condition(filter_chat[0], True, ["ZGROUPCALLCREATORUSERJIDSTRING"], None, "ios")}
{get_chat_condition(filter_chat[1], False, ["ZGROUPCALLCREATORUSERJIDSTRING"], None, "ios")}""")
total_row_number = c.fetchone()[0]
if total_row_number == 0:
return
print(f"\nProcessing calls...({total_row_number})", end="\r")
c.execute(f"""SELECT ZCALLIDSTRING,
ZGROUPCALLCREATORUSERJIDSTRING,
ZGROUPJIDSTRING,
ZDATE,
ZOUTCOME,
ZBYTESRECEIVED + ZBYTESSENT AS bytes_transferred,
ZDURATION,
ZVIDEO,
ZMISSED,
ZINCOMING
FROM ZWACDCALLEVENT
INNER JOIN ZWAAGGREGATECALLEVENT
ON ZWACDCALLEVENT.Z1CALLEVENTS = ZWAAGGREGATECALLEVENT.Z_PK
WHERE 1=1
{get_chat_condition(filter_chat[0], True, ["ZGROUPCALLCREATORUSERJIDSTRING"], None, "ios")}
{get_chat_condition(filter_chat[1], False, ["ZGROUPCALLCREATORUSERJIDSTRING"], None, "ios")}""")
chat = ChatStore(Device.ANDROID, "WhatsApp Calls")
content = c.fetchone()
while content is not None:
ts = APPLE_TIME + int(content["ZDATE"])
call = Message(
from_me=content["ZINCOMING"] == 0,
timestamp=ts,
time=ts,
key_id=content["ZCALLIDSTRING"],
timezone_offset=timezone_offset if timezone_offset else CURRENT_TZ_OFFSET
)
_jid = content["ZGROUPCALLCREATORUSERJIDSTRING"]
name = data[_jid].name if _jid in data else None
if _jid is not None and "@" in _jid:
fallback = _jid.split('@')[0]
else:
fallback = None
call.sender = name or fallback
call.meta = True
call.data = (
f"A {'group ' if content['ZGROUPJIDSTRING'] is not None else ''}"
f"{'video' if content['ZVIDEO'] == 1 else 'voice'} "
f"call {'to' if call.from_me else 'from'} "
f"{call.sender} was "
)
if content['ZOUTCOME'] in (1, 4):
call.data += "not answered." if call.from_me else "missed."
elif content['ZOUTCOME'] == 2:
call.data += "failed."
elif content['ZOUTCOME'] == 0:
call_time = convert_time_unit(int(content['ZDURATION']))
call_bytes = bytes_to_readable(content['bytes_transferred'])
call.data += (
f"initiated and lasted for {call_time} "
f"with {call_bytes} data transferred."
)
else:
call.data += "in an unknown state."
chat.add_message(call.key_id, call)
content = c.fetchone()
data["000000000000000"] = chat

View File

@@ -3,46 +3,64 @@
import shutil
import sqlite3
import os
import time
import getpass
import threading
from Whatsapp_Chat_Exporter.utility import WhatsAppIdentifier
from Whatsapp_Chat_Exporter.bplist import BPListReader
try:
from iphone_backup_decrypt import EncryptedBackup, RelativePath
from iphone_backup_decrypt import FailedToDecryptError, Domain
except ModuleNotFoundError:
support_encrypted = False
else:
support_encrypted = True
def extract_encrypted(base_dir, password):
backup = EncryptedBackup(backup_directory=base_dir, passphrase=password, cleanup=False, check_same_thread=False)
print("Decrypting WhatsApp database...")
try:
backup.extract_file(relative_path=RelativePath.WHATSAPP_MESSAGES,
output_filename="7c7fba66680ef796b916b067077cc246adacf01d")
backup.extract_file(relative_path=RelativePath.WHATSAPP_CONTACTS,
output_filename="b8548dc30aa1030df0ce18ef08b882cf7ab5212f")
except FailedToDecryptError:
print("Failed to decrypt backup: incorrect password?")
exit()
extract_thread = threading.Thread(
target=backup.extract_files_by_domain,
args=(Domain.WHATSAPP, Domain.WHATSAPP)
def extract_encrypted(base_dir, password, identifiers, decrypt_chunk_size):
print("Trying to decrypt the iOS backup...", end="")
backup = EncryptedBackup(
backup_directory=base_dir,
passphrase=password,
cleanup=False,
check_same_thread=False,
decrypt_chunk_size=decrypt_chunk_size
)
extract_thread.daemon = True
extract_thread.start()
dot = 0
while extract_thread.is_alive():
print(f"Decrypting and extracting files{'.' * dot}{' ' * (3 - dot)}", end="\r")
if dot < 3:
dot += 1
time.sleep(0.5)
else:
dot = 0
time.sleep(0.4)
print(f"All required files decrypted and extracted.", end="\n")
extract_thread.handled = True
print("Done\nDecrypting WhatsApp database...", end="")
try:
backup.extract_file(
relative_path=RelativePath.WHATSAPP_MESSAGES,
domain_like=identifiers.DOMAIN,
output_filename=identifiers.MESSAGE
)
backup.extract_file(
relative_path=RelativePath.WHATSAPP_CONTACTS,
domain_like=identifiers.DOMAIN,
output_filename=identifiers.CONTACT
)
backup.extract_file(
relative_path=RelativePath.WHATSAPP_CALLS,
domain_like=identifiers.DOMAIN,
output_filename=identifiers.CALL
)
except ValueError:
print("Failed to decrypt backup: incorrect password?")
exit(7)
except FileNotFoundError:
print("Essential WhatsApp files are missing from the iOS backup.")
exit(6)
else:
print("Done")
def extract_progress_handler(file_id, domain, relative_path, n, total_files):
if n % 100 == 0:
print(f"Decrypting and extracting files...({n}/{total_files})", end="\r")
return True
backup.extract_files(
domain_like=identifiers.DOMAIN,
output_folder=identifiers.DOMAIN,
preserve_folders=True,
filter_callback=extract_progress_handler
)
print(f"All required files are decrypted and extracted. ", end="\n")
return backup
@@ -61,7 +79,7 @@ def is_encrypted(base_dir):
return False
def extract_media(base_dir):
def extract_media(base_dir, identifiers, decrypt_chunk_size):
if is_encrypted(base_dir):
if not support_encrypted:
print("You don't have the dependencies to handle encrypted backup.")
@@ -70,21 +88,28 @@ def extract_media(base_dir):
return False
print("Encryption detected on the backup!")
password = getpass.getpass("Enter the password for the backup:")
extract_encrypted(base_dir, password)
extract_encrypted(base_dir, password, identifiers, decrypt_chunk_size)
else:
wts_db = os.path.join(base_dir, "7c/7c7fba66680ef796b916b067077cc246adacf01d")
contact_db = os.path.join(base_dir, "b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f")
wts_db = os.path.join(base_dir, identifiers.MESSAGE[:2], identifiers.MESSAGE)
contact_db = os.path.join(base_dir, identifiers.CONTACT[:2], identifiers.CONTACT)
call_db = os.path.join(base_dir, identifiers.CALL[:2], identifiers.CALL)
if not os.path.isfile(wts_db):
print("WhatsApp database not found.")
if identifiers is WhatsAppIdentifier:
print("WhatsApp database not found.")
else:
print("WhatsApp Business database not found.")
exit()
else:
shutil.copyfile(wts_db, "7c7fba66680ef796b916b067077cc246adacf01d")
shutil.copyfile(wts_db, identifiers.MESSAGE)
if not os.path.isfile(contact_db):
print("Contact database not found.")
exit()
print("Contact database not found. Skipping...")
else:
shutil.copyfile(contact_db, "b8548dc30aa1030df0ce18ef08b882cf7ab5212f")
_wts_id = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
shutil.copyfile(contact_db, identifiers.CONTACT)
if not os.path.isfile(call_db):
print("Call database not found. Skipping...")
else:
shutil.copyfile(call_db, identifiers.CALL)
_wts_id = identifiers.DOMAIN
with sqlite3.connect(os.path.join(base_dir, "Manifest.db")) as manifest:
manifest.row_factory = sqlite3.Row
c = manifest.cursor()
@@ -98,6 +123,7 @@ def extract_media(base_dir):
c.execute(f"""SELECT fileID,
relativePath,
flags,
file AS metadata,
ROW_NUMBER() OVER(ORDER BY relativePath) AS _index
FROM Files
WHERE domain = '{_wts_id}'
@@ -120,6 +146,10 @@ def extract_media(base_dir):
pass
elif flags == 1:
shutil.copyfile(os.path.join(base_dir, folder, hashes), destination)
metadata = BPListReader(row["metadata"]).parse()
creation = metadata["$objects"][1]["Birth"]
modification = metadata["$objects"][1]["LastModified"]
os.utime(destination, (modification, modification))
if row["_index"] % 100 == 0:
print(f"Extracting WhatsApp files...({row['_index']}/{total_row_number})", end="\r")
row = c.fetchone()

View File

@@ -1,22 +1,83 @@
import jinja2
import json
import os
import unicodedata
import re
import math
from bleach import clean as sanitize
from markupsafe import Markup
from datetime import datetime
from datetime import datetime, timedelta
from enum import IntEnum
from Whatsapp_Chat_Exporter.data_model import ChatStore
try:
from enum import StrEnum
from enum import StrEnum, IntEnum
except ImportError:
# < Python 3.11
# This should be removed when the support for Python 3.10 ends.
from enum import Enum
class StrEnum(str, Enum):
pass
class IntEnum(int, Enum):
pass
MAX_SIZE = 4 * 1024 * 1024 # Default 4MB
ROW_SIZE = 0x3D0
CURRENT_TZ_OFFSET = datetime.now().astimezone().utcoffset().seconds / 3600
def convert_time_unit(time_second: int):
time = str(timedelta(seconds=time_second))
if "day" not in time:
if time_second < 1:
time = "less than a second"
elif time_second == 1:
time = "a second"
elif time_second < 60:
time = time[5:][1 if time_second < 10 else 0:] + " seconds"
elif time_second == 60:
time = "a minute"
elif time_second < 3600:
time = time[2:] + " minutes"
elif time_second == 3600:
time = "an hour"
else:
time += " hour"
return time
def bytes_to_readable(size_bytes: int):
"""From https://stackoverflow.com/a/14822210/9478891
Authors: james-sapam & other contributors
Licensed under CC BY-SA 3.0
See git commit logs for changes, if any.
"""
if size_bytes == 0:
return "0B"
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
i = int(math.floor(math.log(size_bytes, 1024)))
p = math.pow(1024, i)
s = round(size_bytes / p, 2)
return "%s %s" % (s, size_name[i])
def readable_to_bytes(size_str: str):
SIZE_UNITS = {
'B': 1,
'KB': 1024,
'MB': 1024**2,
'GB': 1024**3,
'TB': 1024**4,
'PB': 1024**5,
'EB': 1024**6,
'ZB': 1024**7,
'YB': 1024**8
}
size_str = size_str.upper().strip()
number, unit = size_str[:-2].strip(), size_str[-2:].strip()
if unit not in SIZE_UNITS or not number.isnumeric():
raise ValueError("Invalid input for size_str. Example: 1024GB")
return int(number) * SIZE_UNITS[unit]
def sanitize_except(html):
@@ -71,13 +132,18 @@ def rendering(
msgs,
contact,
w3css,
next,
chat,
headline,
next=False,
previous=False
):
if chat.their_avatar_thumb is None and chat.their_avatar is not None:
their_avatar_thumb = chat.their_avatar
else:
their_avatar_thumb = chat.their_avatar_thumb
if "??" not in headline:
raise ValueError("Headline must contain '??' to replace with name")
headline = headline.replace("??", name)
with open(output_file_name, "w", encoding="utf-8") as f:
f.write(
template.render(
@@ -88,7 +154,10 @@ def rendering(
their_avatar_thumb=their_avatar_thumb,
w3css=w3css,
next=next,
previous=previous,
status=chat.status,
media_base=chat.media_base,
headline=headline
)
)
@@ -134,11 +203,15 @@ def import_from_json(json_file, data):
print(f"Importing chats from JSON...({index + 1}/{total_row_number})", end="\r")
def sanitize_filename(file_name: str):
return "".join(x for x in file_name if x.isalnum() or x in "- ")
def get_file_name(contact: str, chat: ChatStore):
if "@" not in contact and contact not in ("000000000000000", "000000000000001", "ExportedChat"):
raise ValueError("Unexpected contact format: " + contact)
phone_number = contact.split('@')[0]
if "-" in contact:
if "-" in contact and chat.name is not None:
file_name = ""
else:
file_name = phone_number
@@ -146,12 +219,42 @@ def get_file_name(contact: str, chat: ChatStore):
if chat.name is not None:
if file_name != "":
file_name += "-"
file_name += chat.name.replace("/", "-")
file_name += chat.name.replace("/", "-").replace("\\", "-")
name = chat.name
else:
name = phone_number
return "".join(x for x in file_name if x.isalnum() or x in "- "), name
return sanitize_filename(file_name), name
def get_cond_for_empty(enable, jid_field: str, broadcast_field: str):
return f"AND (chat.hidden=0 OR {jid_field}='status@broadcast' OR {broadcast_field}>0)" if enable else ""
def get_chat_condition(filter, include, columns, jid=None, platform=None):
if filter is not None:
conditions = []
if len(columns) < 2 and jid is not None:
raise ValueError("There must be at least two elements in argument columns if jid is not None")
if jid is not None:
if platform == "android":
is_group = f"{jid}.type == 1"
elif platform == "ios":
is_group = f"{jid} IS NOT NULL"
else:
raise ValueError("Only android and ios are supported for argument platform if jid is not None")
for index, chat in enumerate(filter):
if include:
conditions.append(f"{' OR' if index > 0 else ''} {columns[0]} LIKE '%{chat}%'")
if len(columns) > 1:
conditions.append(f" OR ({columns[1]} LIKE '%{chat}%' AND {is_group})")
else:
conditions.append(f"{' AND' if index > 0 else ''} {columns[0]} NOT LIKE '%{chat}%'")
if len(columns) > 1:
conditions.append(f" AND ({columns[1]} NOT LIKE '%{chat}%' AND {is_group})")
return f"AND ({' '.join(conditions)})"
else:
return ""
# Android Specific
@@ -161,6 +264,7 @@ CRYPT14_OFFSETS = (
{"iv": 66, "db": 99},
{"iv": 67, "db": 193},
{"iv": 67, "db": 194},
{"iv": 67, "db": 158},
)
@@ -170,6 +274,11 @@ class Crypt(IntEnum):
CRYPT12 = 12
class DbType(StrEnum):
MESSAGE = "message"
CONTACT = "contact"
def brute_force_offset(max_iv=200, max_db=200):
for iv in range(0, max_iv):
for db in range(0, max_db):
@@ -272,10 +381,10 @@ def get_status_location(output_folder, offline_static):
w3css = os.path.join(offline_static, "w3.css")
def setup_template(template, no_avatar):
if template is None:
def setup_template(template, no_avatar, experimental=False):
if template is None or experimental:
template_dir = os.path.dirname(__file__)
template_file = "whatsapp.html"
template_file = "whatsapp.html" if not experimental else template
else:
template_dir = os.path.dirname(template)
template_file = os.path.basename(template)
@@ -289,4 +398,40 @@ def setup_template(template, no_avatar):
return template_env.get_template(template_file)
# iOS Specific
APPLE_TIME = datetime.timestamp(datetime(2001, 1, 1))
APPLE_TIME = 978307200
def slugify(value, allow_unicode=False):
"""
Taken from https://github.com/django/django/blob/master/django/utils/text.py
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
dashes to single dashes. Remove characters that aren't alphanumerics,
underscores, or hyphens. Convert to lowercase. Also strip leading and
trailing whitespace, dashes, and underscores.
"""
value = str(value)
if allow_unicode:
value = unicodedata.normalize('NFKC', value)
else:
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
value = re.sub(r'[^\w\s-]', '', value.lower())
return re.sub(r'[-\s]+', '-', value).strip('-_')
class WhatsAppIdentifier(StrEnum):
MESSAGE = "7c7fba66680ef796b916b067077cc246adacf01d"
CONTACT = "b8548dc30aa1030df0ce18ef08b882cf7ab5212f"
CALL = "1b432994e958845fffe8e2f190f26d1511534088"
DOMAIN = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
class WhatsAppBusinessIdentifier(StrEnum):
MESSAGE = "724bd3b98b18518b455a87c1f3ac3a0d189c4466"
CONTACT = "d7246a707f51ddf8b17ee2dddabd9e0a4da5c552"
DOMAIN = "AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared"
class JidType(IntEnum):
PM = 0
GROUP = 1
SYSTEM_BROADCAST = 5
STATUS = 11

View File

@@ -0,0 +1,82 @@
import vobject
from typing import List, TypedDict
class ExportedContactNumbers(TypedDict):
full_name: str
numbers: List[str]
class ContactsFromVCards:
def __init__(self) -> None:
self.contact_mapping = []
def is_empty(self):
return self.contact_mapping == []
def load_vcf_file(self, vcf_file_path: str, default_country_code: str):
self.contact_mapping = read_vcards_file(vcf_file_path, default_country_code)
def enrich_from_vcards(self, chats):
for number, name in self.contact_mapping:
# short number must be a bad contact, lets skip it
if len(number) <= 5:
continue
for chat in filter_chats_by_prefix(chats, number).values():
if not hasattr(chat, 'name') or (hasattr(chat, 'name') and chat.name is None):
setattr(chat, 'name', name)
def read_vcards_file(vcf_file_path, default_country_code: str):
contacts = []
with open(vcf_file_path, mode="r", encoding="utf-8") as f:
reader = vobject.readComponents(f)
for row in reader:
if hasattr(row, 'fn'):
name = str(row.fn.value)
elif hasattr(row, 'n'):
name = str(row.n.value)
else:
name = None
if not hasattr(row, 'tel') or name is None:
continue
contact: ExportedContactNumbers = {
"full_name": name,
"numbers": list(map(lambda tel: tel.value, row.tel_list)),
}
contacts.append(contact)
return map_number_to_name(contacts, default_country_code)
def filter_chats_by_prefix(chats, prefix: str):
return {k: v for k, v in chats.items() if k.startswith(prefix)}
def map_number_to_name(contacts, default_country_code: str):
mapping = []
for contact in contacts:
for index, num in enumerate(contact['numbers']):
normalized = normalize_number(num, default_country_code)
if len(contact['numbers']) > 1:
name = f"{contact['full_name']} ({index+1})"
else:
name = contact['full_name']
mapping.append((normalized, name))
return mapping
def normalize_number(number: str, country_code: str):
# Clean the number
number = ''.join(c for c in number if c.isdigit() or c == "+")
# A number that starts with a + or 00 means it already have a country code
for starting_char in ('+', "00"):
if number.startswith(starting_char):
return number[len(starting_char):]
# leading zero should be removed
if starting_char == '0':
number = number[1:]
return country_code + number # fall back

View File

@@ -0,0 +1,20 @@
# from contacts_names_from_vcards import readVCardsFile
from Whatsapp_Chat_Exporter.vcards_contacts import normalize_number, read_vcards_file
def test_readVCardsFile():
assert len(read_vcards_file("contacts.vcf", "973")) > 0
def test_create_number_to_name_dicts():
pass
def test_fuzzy_match_numbers():
pass
def test_normalize_number():
assert normalize_number('0531234567', '1') == '1531234567'
assert normalize_number('001531234567', '2') == '1531234567'
assert normalize_number('+1531234567', '34') == '1531234567'
assert normalize_number('053(123)4567', '34') == '34531234567'
assert normalize_number('0531-234-567', '58') == '58531234567'

View File

@@ -20,7 +20,6 @@
}
footer {
border-top: 2px solid #e3e6e7;
font-size: 2em;
padding: 20px 0 20px 0;
}
article {
@@ -87,10 +86,11 @@
max-height: 100px !important;
}
</style>
<base href="{{ media_base }}" target="_blank">
</head>
<body>
<header class="w3-center w3-top">
Chat history with {{ name }}
{{ headline }}
{% if status is not none %}
<br>
<span class="w3-small">{{ status }}</span>
@@ -120,7 +120,7 @@
{% if msg.reply is not none %}
<div class="reply">
<span class="blue">Replying to </span>
<a href="#{{msg.reply}}" class="reply_link">
<a href="#{{msg.reply}}" target="_self" class="reply_link no-base">
{% if msg.quoted_data is not none %}
"{{msg.quoted_data}}"
{% else %}
@@ -137,21 +137,26 @@
<p>{{ msg.data or 'Not supported WhatsApp internal message' }}</p>
{% endif %}
</div>
{% if msg.caption is not none %}
<div class="w3-container">
{{ msg.caption | urlize(none, true, '_blank') }}
</div>
{% endif %}
{% else %}
{% if msg.media == false %}
{{ msg.data | sanitize_except() }}
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
{% else %}
{% if "image/" in msg.mime %}
<a href="{{ msg.data }}">
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} />
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
</a>
{% elif "audio/" in msg.mime %}
<audio controls="controls" autobuffer="autobuffer">
<source src="{{ msg.data }}" />
</audio>
{% elif "video/" in msg.mime %}
<video controls="controls" autobuffer="autobuffer">
<source src="{{ msg.data }}" />
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
</video>
{% elif "/" in msg.mime %}
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
@@ -161,17 +166,18 @@
{% filter escape %}{{ msg.data }}{% endfilter %}
{% endif %}
{% if msg.caption is not none %}
<br>
{{ msg.caption }}
<div class="w3-container">
{{ msg.caption | urlize(none, true, '_blank') }}
</div>
{% endif %}
{% endif %}
{% endif %}
{% endif %}
</div>
</div>
{% if not no_avatar and my_avatar is not none %}
<div class="w3-col m2 l2 pad-left-10">
<a href="{{ my_avatar }}">
<img src="{{ my_avatar }}" onerror="this.style.display='none'" class="avatar">
<img src="{{ my_avatar }}" onerror="this.style.display='none'" class="avatar" loading="lazy">
</a>
</div>
{% endif %}
@@ -191,9 +197,9 @@
{% if not no_avatar %}
<div class="w3-col m2 l2">
{% if their_avatar is not none %}
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar"></a>
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar" loading="lazy"></a>
{% else %}
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar">
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar" loading="lazy">
{% endif %}
</div>
<div class="w3-col m10 l10">
@@ -204,7 +210,7 @@
{% if msg.reply is not none %}
<div class="reply">
<span class="blue">Replying to </span>
<a href="#{{msg.reply}}" class="reply_link">
<a href="#{{msg.reply}}" target="_self" class="reply_link no-base">
{% if msg.quoted_data is not none %}
"{{msg.quoted_data}}"
{% else %}
@@ -221,21 +227,26 @@
<p>{{ msg.data or 'Not supported WhatsApp internal message' }}</p>
{% endif %}
</div>
{% if msg.caption is not none %}
<div class="w3-container">
{{ msg.caption | urlize(none, true, '_blank') }}
</div>
{% endif %}
{% else %}
{% if msg.media == false %}
{{ msg.data | sanitize_except() }}
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
{% else %}
{% if "image/" in msg.mime %}
<a href="{{ msg.data }}">
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} />
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
</a>
{% elif "audio/" in msg.mime %}
<audio controls="controls" autobuffer="autobuffer">
<source src="{{ msg.data }}" />
</audio>
{% elif "video/" in msg.mime %}
<video controls="controls" autobuffer="autobuffer">
<source src="{{ msg.data }}" />
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
</video>
{% elif "/" in msg.mime %}
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
@@ -245,8 +256,9 @@
{% filter escape %}{{ msg.data }}{% endfilter %}
{% endif %}
{% if msg.caption is not none %}
<br>
{{ msg.caption }}
<div class="w3-container">
{{ msg.caption | urlize(none, true, '_blank') }}
</div>
{% endif %}
{% endif %}
{% endif %}
@@ -259,11 +271,59 @@
</div>
</article>
<footer class="w3-center">
{% if next %}
<a href="./{{ next }}">Next</a>
{% else %}
End of history
<h2>
{% if previous %}
<a href="./{{ previous }}" target="_self">Previous</a>
{% endif %}
<h2>
{% if next %}
<a href="./{{ next }}" target="_self">Next</a>
{% else %}
End of History
{% endif %}
</h2>
<br>
Portions of this page are reproduced from <a href="https://web.dev/articles/lazy-loading-video">work</a> created and <a href="https://developers.google.com/readme/policies">shared by Google</a> and used according to terms described in the <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache 2.0 License</a>.
</footer>
<script>
document.addEventListener("DOMContentLoaded", function() {
var lazyVideos = [].slice.call(document.querySelectorAll("video.lazy"));
if ("IntersectionObserver" in window) {
var lazyVideoObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(video) {
if (video.isIntersecting) {
for (var source in video.target.children) {
var videoSource = video.target.children[source];
if (typeof videoSource.tagName === "string" && videoSource.tagName === "SOURCE") {
videoSource.src = videoSource.dataset.src;
}
}
video.target.load();
video.target.classList.remove("lazy");
lazyVideoObserver.unobserve(video.target);
}
});
});
lazyVideos.forEach(function(lazyVideo) {
lazyVideoObserver.observe(lazyVideo);
});
}
});
</script>
<script>
// Prevent the <base> tag from affecting links with the class "no-base"
document.querySelectorAll('.no-base').forEach(link => {
link.addEventListener('click', function(event) {
const href = this.getAttribute('href');
if (href.startsWith('#')) {
window.location.hash = href;
event.preventDefault();
}
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,424 @@
<!DOCTYPE html>
<html>
<head>
<title>Whatsapp - {{ name }}</title>
<meta charset="UTF-8">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
whatsapp: {
light: '#e7ffdb',
DEFAULT: '#25D366',
dark: '#075E54',
chat: '#efeae2',
'chat-light': '#f0f2f5',
}
}
}
}
}
</script>
<style>
body, html {
height: 100%;
margin: 0;
padding: 0;
scroll-behavior: smooth !important;
}
.chat-list {
height: calc(100vh - 120px);
overflow-y: auto;
}
.message-list {
height: calc(100vh - 90px);
overflow-y: auto;
}
@media (max-width: 640px) {
.chat-list, .message-list {
height: calc(100vh - 108px);
}
}
header {
position: fixed;
z-index: 20;
border-bottom: 2px solid #e3e6e7;
font-size: 2em;
font-weight: bolder;
background-color: white;
padding: 20px 0 20px 0;
}
footer {
margin-top: 10px;
border-top: 2px solid #e3e6e7;
padding: 20px 0 20px 0;
}
article {
width:430px;
margin: auto;
z-index:10;
font-size: 15px;
word-wrap: break-word;
}
img, video, audio{
max-width:100%;
box-sizing: border-box;
}
div.reply{
font-size: 13px;
text-decoration: none;
}
div:target::before {
content: '';
display: block;
height: 115px;
margin-top: -115px;
visibility: hidden;
}
div:target {
animation: 3s highlight;
}
.avatar {
border-radius:50%;
overflow:hidden;
max-width: 64px;
max-height: 64px;
}
.name {
color: #3892da;
}
.pad-left-10 {
padding-left: 10px;
}
.pad-right-10 {
padding-right: 10px;
}
.reply_link {
color: #168acc;
}
.blue {
color: #70777a;
}
.sticker {
max-width: 100px !important;
max-height: 100px !important;
}
@keyframes highlight {
from {
background-color: rgba(37, 211, 102, 0.1);
}
to {
background-color: transparent;
}
}
.search-input {
transform: translateY(-100%);
transition: transform 0.3s ease-in-out;
}
.search-input.active {
transform: translateY(0);
}
.reply-box:active {
background-color:rgb(200 202 205 / var(--tw-bg-opacity, 1));
}
</style>
<script>
function search(event) {
keywords = document.getElementById("mainHeaderSearchInput").value;
hits = [];
document.querySelectorAll(".message-text").forEach(elem => {
if (elem.innerText.trim().includes(keywords)){
hits.push(elem.parentElement.parentElement.id);
}
})
console.log(hits);
}
</script>
<base href="{{ media_base }}" target="_blank">
</head>
<body>
<article class="h-screen bg-whatsapp-chat-light">
<div class="w-full flex flex-col">
<div class="p-3 bg-whatsapp-dark flex items-center justify-between border-l border-[#d1d7db]">
<div class="flex items-center">
{% if not no_avatar %}
<div class="w3-col m2 l2">
{% if their_avatar is not none %}
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="w-10 h-10 rounded-full mr-3" loading="lazy"></a>
{% else %}
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="w-10 h-10 rounded-full mr-3" loading="lazy">
{% endif %}
</div>
{% endif %}
<div>
<h2 class="text-white font-medium">{{ headline }}</h2>
{% if status is not none %}<p class="text-[#8696a0] text-xs">{{ status }}</p>{% endif %}
</div>
</div>
<div class="flex space-x-4">
<!-- <button id="searchButton">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button> -->
<!-- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> -->
{% if previous %}
<a href="./{{ previous }}" target="_self">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5l-7 7 7 7" />
</svg>
</a>
{% endif %}
{% if next %}
<a href="./{{ next }}" target="_self">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
{% endif %}
</div>
<!-- Search Input Overlay -->
<div id="mainSearchInput" class="search-input absolute article top-0 bg-whatsapp-dark p-3 flex items-center space-x-3">
<button id="closeMainSearch" class="text-[#aebac1]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<input type="text" placeholder="Search..." class="flex-1 bg-[#1f2c34] text-white rounded-lg px-3 py-1 focus:outline-none" id="mainHeaderSearchInput" onkeyup="search(event)">
</div>
</div>
</div>
<div class="flex-1 p-5 message-list">
<div class="flex flex-col space-y-2">
<!--Date-->
{% set last = {'last': 946688461.001} %}
{% for msg in msgs -%}
{% if determine_day(last.last, msg.timestamp) is not none %}
<div class="flex justify-center">
<div class="bg-[#e1f2fb] rounded-lg px-2 py-1 text-xs text-[#54656f]">
{{ determine_day(last.last, msg.timestamp) }}
</div>
</div>
{% if last.update({'last': msg.timestamp}) %}{% endif %}
{% endif %}
<!--Actual messages-->
{% if msg.from_me == true %}
<div class="flex justify-end" id="{{ msg.key_id }}">
<div class="bg-whatsapp-light rounded-lg p-2 max-w-[80%] shadow-sm">
{% if msg.reply is not none %}
<a href="#{{msg.reply}}" target="_self" class="no-base">
<div class="mb-2 p-1 bg-whatsapp-chat-light rounded border-l-4 border-whatsapp text-sm reply-box">
<p class="text-whatsapp font-medium text-xs">Replying to</p>
<p class="text-[#111b21] text-xs truncate">
{% if msg.quoted_data is not none %}
"{{msg.quoted_data}}"
{% else %}
this message
{% endif %}
</p>
</div>
</a>
{% endif %}
<p class="text-[#111b21] text-sm message-text">
{% if msg.meta == true or msg.media == false and msg.data is none %}
<div class="flex justify-center mb-2">
<div class="bg-[#FFF3C5] rounded-lg px-3 py-2 text-sm text-[#856404] flex items-center">
{% if msg.safe %}
{{ msg.data | safe or 'Not supported WhatsApp internal message' }}
{% else %}
{{ msg.data or 'Not supported WhatsApp internal message' }}
{% endif %}
</div>
</div>
{% if msg.caption is not none %}
<p>{{ msg.caption | urlize(none, true, '_blank') }}</p>
{% endif %}
{% else %}
{% if msg.media == false %}
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
{% else %}
{% if "image/" in msg.mime %}
<a href="{{ msg.data }}">
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
</a>
{% elif "audio/" in msg.mime %}
<audio controls="controls" autobuffer="autobuffer">
<source src="{{ msg.data }}" />
</audio>
{% elif "video/" in msg.mime %}
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
</video>
{% elif "/" in msg.mime %}
The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a>
{% else %}
{% filter escape %}{{ msg.data }}{% endfilter %}
{% endif %}
{% if msg.caption is not none %}
{{ msg.caption | urlize(none, true, '_blank') }}
{% endif %}
{% endif %}
{% endif %}
</p>
<p class="text-[10px] text-[#667781] text-right mt-1">{{ msg.time }}</p>
</div>
</div>
{% else %}
<div class="flex justify-start" id="{{ msg.key_id }}">
<div class="bg-white rounded-lg p-2 max-w-[80%] shadow-sm">
{% if msg.reply is not none %}
<a href="#{{msg.reply}}" target="_self" class="no-base">
<div class="mb-2 p-1 bg-whatsapp-chat-light rounded border-l-4 border-whatsapp text-sm reply-box">
<p class="text-whatsapp font-medium text-xs">Replying to</p>
<p class="text-[#808080] text-xs truncate">
{% if msg.quoted_data is not none %}
{{msg.quoted_data}}
{% else %}
this message
{% endif %}
</p>
</div>
</a>
{% endif %}
<p class="text-[#111b21] text-sm">
{% if msg.meta == true or msg.media == false and msg.data is none %}
<div class="flex justify-center mb-2">
<div class="bg-[#FFF3C5] rounded-lg px-3 py-2 text-sm text-[#856404] flex items-center">
{% if msg.safe %}
{{ msg.data | safe or 'Not supported WhatsApp internal message' }}
{% else %}
{{ msg.data or 'Not supported WhatsApp internal message' }}
{% endif %}
</div>
</div>
{% if msg.caption is not none %}
<p>{{ msg.caption | urlize(none, true, '_blank') }}</p>
{% endif %}
{% else %}
{% if msg.media == false %}
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
{% else %}
{% if "image/" in msg.mime %}
<a href="{{ msg.data }}">
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
</a>
{% elif "audio/" in msg.mime %}
<audio controls="controls" autobuffer="autobuffer">
<source src="{{ msg.data }}" />
</audio>
{% elif "video/" in msg.mime %}
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
</video>
{% elif "/" in msg.mime %}
The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a>
{% else %}
{% filter escape %}{{ msg.data }}{% endfilter %}
{% endif %}
{% if msg.caption is not none %}
{{ msg.caption | urlize(none, true, '_blank') }}
{% endif %}
{% endif %}
{% endif %}
</p>
<div class="flex items-baseline text-[10px] text-[#667781] mt-1 gap-2">
<span class="flex-shrink-0">
{% if msg.sender is not none %}
{{ msg.sender }}
{% endif %}
</span>
<span class="flex-grow min-w-[4px]"></span>
<span class="flex-shrink-0">{{ msg.time }}</span>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<footer>
<h2 class="text-center">
{% if not next %}
End of History
{% endif %}
</h2>
<br>
Portions of this page are reproduced from <a href="https://web.dev/articles/lazy-loading-video">work</a> created and <a href="https://developers.google.com/readme/policies">shared by Google</a> and used according to terms described in the <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache 2.0 License</a>.
</footer>
</div>
</article>
</body>
<script>
// Search functionality
const searchButton = document.getElementById('searchButton');
const mainSearchInput = document.getElementById('mainSearchInput');
const closeMainSearch = document.getElementById('closeMainSearch');
const mainHeaderSearchInput = document.getElementById('mainHeaderSearchInput');
// Function to show search input
const showSearch = () => {
mainSearchInput.classList.add('active');
mainHeaderSearchInput.focus();
};
// Function to hide search input
const hideSearch = () => {
mainSearchInput.classList.remove('active');
mainHeaderSearchInput.value = '';
};
// Event listeners
searchButton.addEventListener('click', showSearch);
closeMainSearch.addEventListener('click', hideSearch);
// Handle ESC key
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && mainSearchInput.classList.contains('active')) {
hideSearch();
}
});
</script>
<script>
document.addEventListener("DOMContentLoaded", function() {
var lazyVideos = [].slice.call(document.querySelectorAll("video.lazy"));
if ("IntersectionObserver" in window) {
var lazyVideoObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(video) {
if (video.isIntersecting) {
for (var source in video.target.children) {
var videoSource = video.target.children[source];
if (typeof videoSource.tagName === "string" && videoSource.tagName === "SOURCE") {
videoSource.src = videoSource.dataset.src;
}
}
video.target.load();
video.target.classList.remove("lazy");
lazyVideoObserver.unobserve(video.target);
}
});
});
lazyVideos.forEach(function(lazyVideo) {
lazyVideoObserver.observe(lazyVideo);
});
}
});
</script>
<script>
// Prevent the <base> tag from affecting links with the class "no-base"
document.querySelectorAll('.no-base').forEach(link => {
link.addEventListener('click', function(event) {
const href = this.getAttribute('href');
if (href.startsWith('#')) {
window.location.hash = href;
event.preventDefault();
}
});
});
</script>
</html>

19
docs.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript">
destination = {
"filter": "Filter",
"date": "Filters#date-filters",
"chat": "Filters#chat-filter",
"osl": "Open-Source-Licenses"
null: ""
};
const dest = new URLSearchParams(window.location.search).get('dest');
window.location.href = `https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki/${destination[dest]}`;
</script>
</head>
<body>
<p>If the redirection doesn't work, you can find the documentation at <a href="https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki">https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki</a>.</p>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 126 KiB

61
pyproject.toml Normal file
View File

@@ -0,0 +1,61 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "whatsapp-chat-exporter"
version = "0.11.2"
description = "A Whatsapp database parser that provides history of your Whatsapp conversations in HTML and JSON. Android, iOS, iPadOS, Crypt12, Crypt14, Crypt15 supported."
readme = "README.md"
authors = [
{ name = "KnugiHK", email = "hello@knugi.com" }
]
license = { text = "MIT" }
keywords = [
"android", "ios", "parsing", "history", "iphone", "message", "crypt15",
"customizable", "whatsapp", "android-backup", "messages", "crypt14",
"crypt12", "whatsapp-chat-exporter", "whatsapp-export", "iphone-backup",
"whatsapp-database", "whatsapp-database-parser", "whatsapp-conversations"
]
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"Topic :: Communications :: Chat",
"Topic :: Utilities",
"Topic :: Database"
]
requires-python = ">=3.9"
dependencies = [
"jinja2",
"bleach"
]
[project.optional-dependencies]
android_backup = ["pycryptodome", "javaobj-py3"]
crypt12 = ["pycryptodome"]
crypt14 = ["pycryptodome"]
crypt15 = ["pycryptodome", "javaobj-py3"]
all = ["pycryptodome", "javaobj-py3", "vobject"]
everything = ["pycryptodome", "javaobj-py3", "vobject"]
backup = ["pycryptodome", "javaobj-py3"]
vcards = ["vobject", "pycryptodome", "javaobj-py3"]
[project.scripts]
wtsexporter = "Whatsapp_Chat_Exporter.__main__:main"
waexporter = "Whatsapp_Chat_Exporter.__main__:main"
whatsapp-chat-exporter = "Whatsapp_Chat_Exporter.__main__:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["Whatsapp_Chat_Exporter"]
[tool.setuptools.package-data]
Whatsapp_Chat_Exporter = ["*.html"]

View File

@@ -0,0 +1,48 @@
import hmac
import javaobj
import zlib
from Crypto.Cipher import AES
from hashlib import sha256
def _generate_hmac_of_hmac(key_stream):
key = hmac.new(
hmac.new(
b'\x00' * 32,
key_stream,
sha256
).digest(),
b"backup encryption\x01",
sha256
)
return key.digest(), key_stream
def _extract_encrypted_key(keyfile):
key_stream = b""
for byte in javaobj.loads(keyfile):
key_stream += byte.to_bytes(1, "big", signed=True)
return _generate_hmac_of_hmac(key_stream)
key = open("encrypted_backup.key", "rb").read()
database = open("wa.db.crypt15", "rb").read()
main_key, hex_key = _extract_encrypted_key(key)
for i in range(100):
iv = database[i:i+16]
for j in range(100):
cipher = AES.new(main_key, AES.MODE_GCM, iv)
db_ciphertext = database[j:]
db_compressed = cipher.decrypt(db_ciphertext)
try:
db = zlib.decompress(db_compressed)
except zlib.error:
...
else:
if db[0:6] == b"SQLite":
print(f"Found!\nIV: {i}\nOffset: {j}")
print(db_compressed[:10])
exit()
print("Not found! Try to increase maximum search.")

View File

@@ -1,69 +0,0 @@
import setuptools
from re import search
with open("README.md", "r") as fh:
long_description = fh.read()
with open("Whatsapp_Chat_Exporter/__init__.py", encoding="utf8") as f:
version = search(r'__version__ = "(.*?)"', f.read()).group(1)
setuptools.setup(
name="whatsapp-chat-exporter",
version=version,
author="KnugiHK",
author_email="hello@knugi.com",
description=("A Whatsapp database parser that will give you the "
"history of your Whatsapp conversations in HTML and JSON. "
"Android, iOS, iPadOS, Crypt12, Crypt14, Crypt15 supported."),
long_description=long_description,
long_description_content_type="text/markdown",
license="MIT",
keywords=[
"android", "ios", "parsing", "history", "iphone", "message", "crypt15",
"customizable", "whatsapp", "android-backup", "messages", "crypt14",
"crypt12", "whatsapp-chat-exporter", "whatsapp-export", "iphone-backup",
"whatsapp-database", "whatsapp-database-parser", "whatsapp-conversations"
],
platforms=["any"],
url="https://github.com/KnugiHK/Whatsapp-Chat-Exporter",
packages=setuptools.find_packages(),
package_data={
'': ['whatsapp.html']
},
classifiers=[
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"Topic :: Communications :: Chat",
"Topic :: Utilities",
"Topic :: Database"
],
python_requires='>=3.8',
install_requires=[
'jinja2',
'bleach'
],
extras_require={
'android_backup': ["pycryptodome", "javaobj-py3"],
'crypt12': ["pycryptodome"],
'crypt14': ["pycryptodome"],
'crypt15': ["pycryptodome", "javaobj-py3"],
'all': ["pycryptodome", "javaobj-py3"],
'everything': ["pycryptodome", "javaobj-py3"],
'backup': ["pycryptodome", "javaobj-py3"]
},
entry_points={
"console_scripts": [
"wtsexporter = Whatsapp_Chat_Exporter.__main__:main",
"waexporter = Whatsapp_Chat_Exporter.__main__:main",
"whatsapp-chat-exporter = Whatsapp_Chat_Exporter.__main__:main"
]
}
)