├── .gitignore ├── LICENSE.md ├── README.md ├── messyger.py ├── poetry.lock └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021–2022 [Radian LLC](https://radian.codes) and 4 | contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Messyger 2 | 3 | Code to accompany . 4 | 5 | **NOTE:** This is somewhat of a historical artifact, because companies 6 | like Facebook are always changing their internal APIs, both for 7 | legitimate optimization purposes as well as simply to keep end users 8 | from having too much control over their digital lives. So, the current 9 | code does not work exactly as printed without a bit of tweaking. 10 | 11 | Updating the code would bring it out of sync with the blog post and 12 | cause confusion, so I'm leaving it be for the moment. But, you can see 13 | [Unzuckify](https://github.com/radian-software/unzuckify) for the code 14 | that inspired the blog post, which I am keeping up to date (at least 15 | for the next few years, before I drop support for Messenger entirely). 16 | 17 | By combining Messyger and Unzuckify, and perhaps some debugging skills 18 | of your own, I think you'll be able to get something working if you 19 | really want to. 20 | -------------------------------------------------------------------------------- /messyger.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import collections 3 | import datetime 4 | import json 5 | import random 6 | import re 7 | 8 | import esprima 9 | import requests 10 | 11 | ## Get the email and password 12 | 13 | parser = argparse.ArgumentParser("messyger") 14 | parser.add_argument("-u", "--email", required=True) 15 | parser.add_argument("-p", "--password", required=True) 16 | parser.add_argument("-m", "--message") 17 | parser.add_argument("-r", "--recipient", type=int) 18 | args = parser.parse_args() 19 | 20 | ## Parse the HTML response 21 | 22 | html_resp = requests.get("https://www.messenger.com") 23 | html_resp.raise_for_status() 24 | html_page = html_resp.text 25 | 26 | initial_request_id = re.search( 27 | r'name="initial_request_id" value="([^"]+)"', html_page 28 | ).group(1) 29 | 30 | lsd = re.search(r'name="lsd" value="([^"]+)"', html_page).group(1) 31 | 32 | datr = re.search(r'"_js_datr","([^"]+)"', html_page).group(1) 33 | 34 | ## Make the login request 35 | 36 | login = requests.post( 37 | "https://www.messenger.com/login/password/", 38 | cookies={"datr": datr}, 39 | data={ 40 | "lsd": lsd, 41 | "initial_request_id": initial_request_id, 42 | "email": args.email, 43 | "pass": args.password, 44 | }, 45 | allow_redirects=False, 46 | ) 47 | assert login.status_code == 302 48 | 49 | ## Extract the inbox query parameters 50 | 51 | inbox_html_resp = requests.get("https://www.messenger.com", cookies=login.cookies) 52 | inbox_html_resp.raise_for_status() 53 | inbox_html_page = inbox_html_resp.text 54 | 55 | dtsg = re.search(r'"DTSGInitialData",\[\],\{"token":"([^"]+)"', inbox_html_page).group( 56 | 1 57 | ) 58 | 59 | device_id = re.search(r'"deviceId":"([^"]+)"', inbox_html_page).group(1) 60 | 61 | schema_version = re.search(r'"schemaVersion":"([0-9]+)"', inbox_html_page).group(1) 62 | 63 | script_urls = re.findall(r'"([^"]+rsrc\.php/[^"]+\.js[^"]+)"', inbox_html_page) 64 | 65 | scripts = [] 66 | for url in script_urls: 67 | resp = requests.get(url) 68 | resp.raise_for_status() 69 | scripts.append(resp.text) 70 | 71 | for script in scripts: 72 | if "LSPlatformGraphQLLightspeedRequestQuery" not in script: 73 | continue 74 | doc_id = re.search( 75 | r'id:"([0-9]+)",metadata:\{\},name:"LSPlatformGraphQLLightspeedRequestQuery"', 76 | script, 77 | ).group(1) 78 | break 79 | 80 | if not args.message: 81 | 82 | inbox_resp = requests.post( 83 | "https://www.messenger.com/api/graphql/", 84 | cookies=login.cookies, 85 | data={ 86 | "fb_dtsg": dtsg, 87 | "doc_id": doc_id, 88 | "variables": json.dumps( 89 | { 90 | "deviceId": device_id, 91 | "requestId": 0, 92 | "requestPayload": json.dumps( 93 | { 94 | "database": 1, 95 | "version": schema_version, 96 | "sync_params": json.dumps({}), 97 | } 98 | ), 99 | "requestType": 1, 100 | } 101 | ), 102 | }, 103 | ) 104 | inbox_resp.raise_for_status() 105 | 106 | ## Parse the inbox data response 107 | 108 | inbox_json = inbox_resp.json() 109 | inbox_js = inbox_json["data"]["viewer"]["lightspeed_web_request"]["payload"] 110 | 111 | ast = esprima.parseScript(inbox_js) 112 | 113 | def is_lightspeed_call(node): 114 | return ( 115 | node.type == "CallExpression" 116 | and node.callee.type == "MemberExpression" 117 | and node.callee.object.type == "Identifier" 118 | and node.callee.object.name == "LS" 119 | and node.callee.property.type == "Identifier" 120 | and node.callee.property.name == "sp" 121 | ) 122 | 123 | def parse_argument(node): 124 | if node.type == "Literal": 125 | return node.value 126 | if node.type == "ArrayExpression": 127 | assert len(node.elements) == 2 128 | high_bits, low_bits = map(parse_argument, node.elements) 129 | return (high_bits << 32) + low_bits 130 | if node.type == "UnaryExpression" and node.prefix and node.operator == "-": 131 | return -parse_argument(node.argument) 132 | 133 | fn_calls = collections.defaultdict(list) 134 | 135 | def handle_node(node, meta): 136 | if not is_lightspeed_call(node): 137 | return 138 | 139 | args = [parse_argument(arg) for arg in node.arguments] 140 | (fn_name, *fn_args) = args 141 | 142 | fn_calls[fn_name].append(fn_args) 143 | 144 | esprima.parseScript(inbox_js, delegate=handle_node) 145 | 146 | conversations = collections.defaultdict(dict) 147 | 148 | for args in fn_calls["deleteThenInsertThread"]: 149 | last_sent_ts, last_read_ts, last_msg, *rest = args 150 | user_id, last_msg_author = [ 151 | arg for arg in rest if isinstance(arg, int) and arg > 1e14 152 | ] 153 | conversations[user_id]["unread"] = last_sent_ts != last_read_ts 154 | conversations[user_id]["last_message"] = last_msg 155 | conversations[user_id]["last_message_author"] = last_msg_author 156 | 157 | for args in fn_calls["verifyContactRowExists"]: 158 | user_id, _, _, name, *rest = args 159 | conversations[user_id]["name"] = name 160 | 161 | print(json.dumps(conversations, indent=2)) 162 | 163 | else: 164 | 165 | ## Replicate the send-message request 166 | 167 | timestamp = int(datetime.datetime.now().timestamp() * 1000) 168 | epoch = timestamp << 22 169 | otid = epoch + random.randrange(2 ** 22) 170 | 171 | send_message_resp = requests.post( 172 | "https://www.messenger.com/api/graphql/", 173 | cookies=login.cookies, 174 | data={ 175 | "fb_dtsg": dtsg, 176 | "doc_id": doc_id, 177 | "variables": json.dumps( 178 | { 179 | "deviceId": device_id, 180 | "requestId": 0, 181 | "requestPayload": json.dumps( 182 | { 183 | "version_id": str(schema_version), 184 | "tasks": [ 185 | { 186 | "label": "46", 187 | "payload": json.dumps( 188 | { 189 | "thread_id": args.recipient, 190 | "otid": "6870463702739115830", 191 | "source": 0, 192 | "send_type": 1, 193 | "text": args.message, 194 | "initiating_source": 1, 195 | } 196 | ), 197 | "queue_name": str(args.recipient), 198 | "task_id": 0, 199 | "failure_count": None, 200 | }, 201 | { 202 | "label": "21", 203 | "payload": json.dumps( 204 | { 205 | "thread_id": args.recipient, 206 | "last_read_watermark_ts": timestamp, 207 | "sync_group": 1, 208 | } 209 | ), 210 | "queue_name": str(args.recipient), 211 | "task_id": 1, 212 | "failure_count": None, 213 | }, 214 | ], 215 | "epoch_id": 6870463702858032000, 216 | } 217 | ), 218 | "requestType": 3, 219 | } 220 | ), 221 | }, 222 | ) 223 | 224 | print(send_message_resp.text) 225 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "certifi" 5 | version = "2024.7.4" 6 | description = "Python package for providing Mozilla's CA Bundle." 7 | optional = false 8 | python-versions = ">=3.6" 9 | groups = ["main"] 10 | files = [ 11 | {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, 12 | {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, 13 | ] 14 | 15 | [[package]] 16 | name = "charset-normalizer" 17 | version = "2.0.7" 18 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 19 | optional = false 20 | python-versions = ">=3.5.0" 21 | groups = ["main"] 22 | files = [ 23 | {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"}, 24 | {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"}, 25 | ] 26 | 27 | [package.extras] 28 | unicode-backport = ["unicodedata2"] 29 | 30 | [[package]] 31 | name = "esprima" 32 | version = "4.0.1" 33 | description = "ECMAScript parsing infrastructure for multipurpose analysis in Python" 34 | optional = false 35 | python-versions = "*" 36 | groups = ["main"] 37 | files = [ 38 | {file = "esprima-4.0.1.tar.gz", hash = "sha256:08db1a876d3c2910db9cfaeb83108193af5411fc3a3a66ebefacd390d21323ee"}, 39 | ] 40 | 41 | [[package]] 42 | name = "idna" 43 | version = "3.7" 44 | description = "Internationalized Domain Names in Applications (IDNA)" 45 | optional = false 46 | python-versions = ">=3.5" 47 | groups = ["main"] 48 | files = [ 49 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, 50 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, 51 | ] 52 | 53 | [[package]] 54 | name = "requests" 55 | version = "2.32.4" 56 | description = "Python HTTP for Humans." 57 | optional = false 58 | python-versions = ">=3.8" 59 | groups = ["main"] 60 | files = [ 61 | {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, 62 | {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, 63 | ] 64 | 65 | [package.dependencies] 66 | certifi = ">=2017.4.17" 67 | charset_normalizer = ">=2,<4" 68 | idna = ">=2.5,<4" 69 | urllib3 = ">=1.21.1,<3" 70 | 71 | [package.extras] 72 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 73 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 74 | 75 | [[package]] 76 | name = "urllib3" 77 | version = "1.26.19" 78 | description = "HTTP library with thread-safe connection pooling, file post, and more." 79 | optional = false 80 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 81 | groups = ["main"] 82 | files = [ 83 | {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, 84 | {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, 85 | ] 86 | 87 | [package.extras] 88 | brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] 89 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] 90 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 91 | 92 | [metadata] 93 | lock-version = "2.1" 94 | python-versions = ">=3.8,<4.0" 95 | content-hash = "7d4a47fbe7a09d6df7be1165d4b628184622602f1a10f6331c1ad5eca70c510b" 96 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "messyger" 3 | version = "0.1.0" 4 | description = "Messy Messenger client" 5 | authors = ["Radian LLC "] 6 | 7 | [tool.poetry.dependencies] 8 | python = ">=3.8,<4.0" 9 | requests = "^2.32.4" 10 | esprima = "^4.0.1" 11 | 12 | [tool.poetry.dev-dependencies] 13 | 14 | [build-system] 15 | requires = ["poetry-core>=1.0.0"] 16 | build-backend = "poetry.core.masonry.api" 17 | --------------------------------------------------------------------------------