Compare commits

..

16 Commits

Author SHA1 Message Date
Mads Marquart
520258e339 Bump version: 2.0.0a3 -> 2.0.0a4 2020-06-07 12:52:59 +02:00
Mads Marquart
435dfaf6d8 Better GraphQL error reporting 2020-06-07 12:48:21 +02:00
Mads Marquart
cf0e1e3a93 Test on_2fa_callback with authentication applications 2020-06-07 12:37:36 +02:00
Mads Marquart
2319fc7c4a Handle early return from two_factor_helper 2020-06-07 12:35:24 +02:00
Mads Marquart
b35240bdda Handle locked accounts 2020-06-07 12:35:07 +02:00
Mads Marquart
6141cc5a41 Update SERVER_JS_DEFINE_REGEX 2020-06-07 12:04:51 +02:00
Mads Marquart
b1e438dae1 Few fixes to 2FA flow 2020-05-16 19:30:03 +02:00
Mads Marquart
3c0f411be7 Fix typo in 2FA logic 2020-05-10 12:01:41 +02:00
Mads Marquart
9ad0090b02 Merge pull request #563 from smilexs4/patch-2
Fix typo in example
2020-05-10 11:53:56 +02:00
Mads Marquart
bec151a560 Merge pull request #562 from smilexs4/patch-1
Fix typo in example
2020-05-10 11:53:39 +02:00
smilexs4
2087182ecf Update interract.py
Changed fbchat.Message parameter from session to thread
2020-05-08 18:45:25 +03:00
smilexs4
09627b71ae Update fetch.py
Solved the exception:

TypeError: __init__() takes 1 positional argument but 2 were given
2020-05-08 17:08:01 +03:00
Mads Marquart
078bf9fc16 Add send online tests 2020-05-07 12:26:39 +02:00
Mads Marquart
d33e36866d Finish Client online tests 2020-05-07 12:10:45 +02:00
Mads Marquart
2a382ffaed Fix Client.mark_as_(un)read, and add tests 2020-05-07 11:59:05 +02:00
Mads Marquart
18a3ffb90d Fix Client.fetch_image_url in some cases
Sometimes (or always?), jsmods require includes a JS version specifier.

This means we couldn't find the url
2020-05-07 11:46:42 +02:00
13 changed files with 184 additions and 29 deletions

View File

@@ -2,7 +2,7 @@ import fbchat
session = fbchat.Session.login("<email>", "<password>") session = fbchat.Session.login("<email>", "<password>")
client = fbchat.Client(session) client = fbchat.Client(session=session)
# Fetches a list of all users you're currently chatting with, as `User` objects # Fetches a list of all users you're currently chatting with, as `User` objects
users = client.fetch_all_users() users = client.fetch_all_users()

View File

@@ -60,7 +60,7 @@ thread.set_color("#0084ff")
# Will change the thread emoji to `👍` # Will change the thread emoji to `👍`
thread.set_emoji("👍") thread.set_emoji("👍")
message = fbchat.Message(session=session, id="<message id>") message = fbchat.Message(thread=thread, id="<message id>")
# Will react to a message with a 😍 emoji # Will react to a message with a 😍 emoji
message.react("😍") message.react("😍")

View File

@@ -118,7 +118,7 @@ from ._listen import Listener
from ._client import Client from ._client import Client
__version__ = "2.0.0a3" __version__ = "2.0.0a4"
__all__ = ("Session", "Listener", "Client") __all__ = ("Session", "Listener", "Client")

View File

@@ -558,7 +558,7 @@ class Client:
"shouldSendReadReceipt": "true", "shouldSendReadReceipt": "true",
} }
for threads in threads: for thread in threads:
data["ids[{}]".format(thread.id)] = "true" if read else "false" data["ids[{}]".format(thread.id)] = "true" if read else "false"
j = self.session._payload_post("/ajax/mercury/change_read_status.php", data) j = self.session._payload_post("/ajax/mercury/change_read_status.php", data)

View File

@@ -124,11 +124,11 @@ def handle_graphql_errors(j):
errors = j["errors"] errors = j["errors"]
if errors: if errors:
error = errors[0] # TODO: Handle multiple errors error = errors[0] # TODO: Handle multiple errors
# TODO: Use `severity` and `description` # TODO: Use `severity`
raise GraphQLError( raise GraphQLError(
# TODO: What data is always available? # TODO: What data is always available?
message=error.get("summary", "Unknown error"), message=error.get("summary", "Unknown error"),
description=error.get("message", ""), description=error.get("message") or error.get("description") or "",
code=error.get("code"), code=error.get("code"),
debug_info=error.get("debug_info"), debug_info=error.get("debug_info"),
) )

View File

@@ -15,7 +15,9 @@ from . import _graphql, _util, _exception
from typing import Optional, Mapping, Callable, Any from typing import Optional, Mapping, Callable, Any
SERVER_JS_DEFINE_REGEX = re.compile(r'require\("ServerJSDefine"\)\)?\.handleDefines\(') SERVER_JS_DEFINE_REGEX = re.compile(
r'require(?:\("ServerJS"\).{,100}\.handle\({.*"define":)|(?:\("ServerJSDefine"\)\)?\.handleDefines\()'
)
SERVER_JS_DEFINE_JSON_DECODER = json.JSONDecoder() SERVER_JS_DEFINE_JSON_DECODER = json.JSONDecoder()
@@ -24,6 +26,8 @@ def parse_server_js_define(html: str) -> Mapping[str, Any]:
# Find points where we should start parsing # Find points where we should start parsing
define_splits = SERVER_JS_DEFINE_REGEX.split(html) define_splits = SERVER_JS_DEFINE_REGEX.split(html)
# TODO: Extract jsmods "require" and "define" from `bigPipe.onPageletArrive`?
# Skip leading entry # Skip leading entry
_, *define_splits = define_splits _, *define_splits = define_splits
@@ -106,7 +110,7 @@ def find_form_request(html: str):
form = soup.form form = soup.form
if not form: if not form:
raise _exception.ParseError("Could not find form to submit", data=soup) raise _exception.ParseError("Could not find form to submit", data=html)
url = form.get("action") url = form.get("action")
if not url: if not url:
@@ -130,10 +134,11 @@ def two_factor_helper(session: requests.Session, r, on_2fa_callback):
# You don't have to type a code if your device is already saved # You don't have to type a code if your device is already saved
# Repeats if you get the code wrong # Repeats if you get the code wrong
while "approvals_code" not in data: while "approvals_code" in data:
data["approvals_code"] = on_2fa_callback() data["approvals_code"] = on_2fa_callback()
log.info("Submitting 2FA code") log.info("Submitting 2FA code")
r = session.post(url, data=data, allow_redirects=False) r = session.post(url, data=data, allow_redirects=False)
log.debug("2FA location: %s", r.headers.get("Location"))
url, data = find_form_request(r.content.decode("utf-8")) url, data = find_form_request(r.content.decode("utf-8"))
# TODO: Can be missing if checkup flow was done on another device in the meantime? # TODO: Can be missing if checkup flow was done on another device in the meantime?
@@ -141,18 +146,28 @@ def two_factor_helper(session: requests.Session, r, on_2fa_callback):
data["name_action_selected"] = "save_device" data["name_action_selected"] = "save_device"
log.info("Saving browser") log.info("Saving browser")
r = session.post(url, data=data, allow_redirects=False) r = session.post(url, data=data, allow_redirects=False)
log.debug("2FA location: %s", r.headers.get("Location"))
url = r.headers.get("Location")
if url and url.startswith("https://www.messenger.com/login/auth_token/"):
return url
url, data = find_form_request(r.content.decode("utf-8")) url, data = find_form_request(r.content.decode("utf-8"))
log.info("Starting Facebook checkup flow") log.info("Starting Facebook checkup flow")
r = session.post(url, data=data, allow_redirects=False) r = session.post(url, data=data, allow_redirects=False)
log.debug("2FA location: %s", r.headers.get("Location"))
url, data = find_form_request(r.content.decode("utf-8")) url, data = find_form_request(r.content.decode("utf-8"))
if "verification_method" in data:
raise _exception.NotLoggedIn(
"Your account is locked, and you need to log in using a browser, and verify it there!"
)
if "submit[This was me]" not in data or "submit[This wasn't me]" not in data: if "submit[This was me]" not in data or "submit[This wasn't me]" not in data:
raise _exception.ParseError("Could not fill out form properly (2)", data=data) raise _exception.ParseError("Could not fill out form properly (2)", data=data)
data["submit[This was me]"] = "[any value]" data["submit[This was me]"] = "[any value]"
del data["submit[This wasn't me]"] del data["submit[This wasn't me]"]
log.info("Verifying login attempt") log.info("Verifying login attempt")
r = session.post(url, data=data, allow_redirects=False) r = session.post(url, data=data, allow_redirects=False)
log.debug("2FA location: %s", r.headers.get("Location"))
url, data = find_form_request(r.content.decode("utf-8")) url, data = find_form_request(r.content.decode("utf-8"))
if "name_action_selected" not in data: if "name_action_selected" not in data:
@@ -160,8 +175,7 @@ def two_factor_helper(session: requests.Session, r, on_2fa_callback):
data["name_action_selected"] = "save_device" data["name_action_selected"] = "save_device"
log.info("Saving device again") log.info("Saving device again")
r = session.post(url, data=data, allow_redirects=False) r = session.post(url, data=data, allow_redirects=False)
log.debug("2FA location: %s", r.headers.get("Location"))
print(r.status_code, r.url, r.headers)
return r.headers.get("Location") return r.headers.get("Location")
@@ -232,7 +246,9 @@ class Session:
on_2fa_callback: Function that will be called, in case a two factor on_2fa_callback: Function that will be called, in case a two factor
authentication code is needed. This should return the requested code. authentication code is needed. This should return the requested code.
Only tested with SMS codes, might not work with authentication apps. Tested using SMS and authentication applications. If you have both
enabled, you might not receive an SMS code, and you'll have to use the
authentication application.
Note: Facebook limits the amount of codes they will give you, so if you Note: Facebook limits the amount of codes they will give you, so if you
don't receive a code, be patient, and try again later! don't receive a code, be patient, and try again later!
@@ -292,15 +308,22 @@ class Session:
raise _exception.NotLoggedIn( raise _exception.NotLoggedIn(
"2FA code required! Please supply `on_2fa_callback` to .login" "2FA code required! Please supply `on_2fa_callback` to .login"
) )
# Get a facebook.com url that handles the 2FA flow # Get a facebook.com/checkpoint/start url that handles the 2FA flow
# This probably works differently for Messenger-only accounts # This probably works differently for Messenger-only accounts
url = _util.get_url_parameter(url, "next") url = _util.get_url_parameter(url, "next")
# Explicitly allow redirects if not url.startswith("https://www.facebook.com/checkpoint/start/"):
r = session.get(url, allow_redirects=True) raise _exception.ParseError("Failed 2fa flow (1)", data=url)
r = session.get(url, allow_redirects=False)
url = r.headers.get("Location")
if not url or not url.startswith("https://www.facebook.com/checkpoint/"):
raise _exception.ParseError("Failed 2fa flow (2)", data=url)
r = session.get(url, allow_redirects=False)
url = two_factor_helper(session, r, on_2fa_callback) url = two_factor_helper(session, r, on_2fa_callback)
if not url.startswith("https://www.messenger.com/login/auth_token/"): if not url.startswith("https://www.messenger.com/login/auth_token/"):
raise _exception.ParseError("Failed 2fa flow", data=url) raise _exception.ParseError("Failed 2fa flow (3)", data=url)
r = session.get(url, allow_redirects=False) r = session.get(url, allow_redirects=False)
url = r.headers.get("Location") url = r.headers.get("Location")

View File

@@ -211,7 +211,7 @@ class ThreadABC(metaclass=abc.ABCMeta):
Example: Example:
Send a pinned location in Beijing, China. Send a pinned location in Beijing, China.
>>> thread.send_location(39.9390731, 116.117273) >>> thread.send_pinned_location(39.9390731, 116.117273)
""" """
self._send_location(False, latitude=latitude, longitude=longitude) self._send_location(False, latitude=latitude, longitude=longitude)

View File

@@ -63,16 +63,19 @@ def generate_offline_threading_id():
return str(int(msgs, 2)) return str(int(msgs, 2))
def remove_version_from_module(module):
return module.split("@", 1)[0]
def get_jsmods_require(require) -> Mapping[str, Sequence[Any]]: def get_jsmods_require(require) -> Mapping[str, Sequence[Any]]:
rtn = {} rtn = {}
for item in require: for item in require:
if len(item) == 1: if len(item) == 1:
(module,) = item (module,) = item
rtn[module] = [] rtn[remove_version_from_module(module)] = []
continue continue
method = "{}.{}".format(item[0], item[1]) module, method, requirements, arguments = item
requirements = item[2] method = "{}.{}".format(remove_version_from_module(module), method)
arguments = item[3]
rtn[method] = arguments rtn[method] = arguments
return rtn return rtn

View File

@@ -17,12 +17,51 @@ def session(pytestconfig):
pytestconfig.cache.set("session_cookies", session.get_cookies()) pytestconfig.cache.set("session_cookies", session.get_cookies())
# TODO: Allow the main session object to be closed - and perhaps used in `with`?
session._session.close()
@pytest.fixture @pytest.fixture
def client(session): def client(session):
return fbchat.Client(session=session) return fbchat.Client(session=session)
@pytest.fixture(scope="session")
def user(pytestconfig, session):
user_id = pytestconfig.cache.get("user_id", None)
if not user_id:
user_id = input("A user you're chatting with's id: ")
pytestconfig.cache.set("user_id", user_id)
return fbchat.User(session=session, id=user_id)
@pytest.fixture(scope="session")
def group(pytestconfig, session):
group_id = pytestconfig.cache.get("group_id", None)
if not group_id:
group_id = input("A group you're chatting with's id: ")
pytestconfig.cache.set("group_id", group_id)
return fbchat.Group(session=session, id=group_id)
@pytest.fixture(
scope="session",
params=[
"user",
"group",
"self",
pytest.param("invalid", marks=[pytest.mark.xfail()]),
],
)
def any_thread(request, session, user, group):
return {
"user": user,
"group": group,
"self": session.user,
"invalid": fbchat.Thread(session=session, id="0"),
}[request.param]
@pytest.fixture @pytest.fixture
def listener(session): def listener(session):
return fbchat.Listener(session=session, chat_on=False, foreground=False) return fbchat.Listener(session=session, chat_on=False, foreground=False)

View File

@@ -1,5 +1,6 @@
import pytest import pytest
import fbchat import fbchat
import datetime
import os import os
pytestmark = pytest.mark.online pytestmark = pytest.mark.online
@@ -46,11 +47,6 @@ def test_undocumented(client):
client.fetch_unseen() client.fetch_unseen()
@pytest.mark.skip(reason="need a way to get an image id")
def test_fetch_image_url(client):
client.fetch_image_url("TODO")
@pytest.fixture @pytest.fixture
def open_resource(pytestconfig): def open_resource(pytestconfig):
def get_resource_inner(filename): def get_resource_inner(filename):
@@ -60,6 +56,14 @@ def open_resource(pytestconfig):
return get_resource_inner return get_resource_inner
def test_upload_and_fetch_image_url(client, open_resource):
with open_resource("image.png") as f:
((id, mimetype),) = client.upload([("image.png", f, "image/png")])
assert mimetype == "image/png"
assert client.fetch_image_url(id).startswith("http")
def test_upload_image(client, open_resource): def test_upload_image(client, open_resource):
with open_resource("image.png") as f: with open_resource("image.png") as f:
_ = client.upload([("image.png", f, "image/png")]) _ = client.upload([("image.png", f, "image/png")])
@@ -90,5 +94,24 @@ def test_upload_many(client, open_resource):
) )
# def test_mark_as_read(client): def test_mark_as_read(client, user, group):
# client.mark_as_read([thread1, thread2]) client.mark_as_read([user, group], datetime.datetime.now())
def test_mark_as_unread(client, user, group):
client.mark_as_unread([user, group], datetime.datetime.now())
def test_move_threads(client, user, group):
client.move_threads(fbchat.ThreadLocation.PENDING, [user, group])
client.move_threads(fbchat.ThreadLocation.INBOX, [user, group])
@pytest.mark.skip(reason="need to have threads to delete")
def test_delete_threads():
pass
@pytest.mark.skip(reason="need to have messages to delete")
def test_delete_messages():
pass

42
tests/online/test_send.py Normal file
View File

@@ -0,0 +1,42 @@
import pytest
import fbchat
pytestmark = pytest.mark.online
# TODO: Verify return values
def test_wave(any_thread):
assert any_thread.wave(True)
assert any_thread.wave(False)
def test_send_text(any_thread):
assert any_thread.send_text("Test")
def test_send_text_with_mention(any_thread):
mention = fbchat.Mention(thread_id=any_thread.id, offset=5, length=8)
assert any_thread.send_text("Test @mention", mentions=[mention])
def test_send_emoji(any_thread):
assert any_thread.send_emoji("😀", size=fbchat.EmojiSize.LARGE)
def test_send_sticker(any_thread):
assert any_thread.send_sticker("1889713947839631")
def test_send_location(any_thread):
any_thread.send_location(51.5287718, -0.2416815)
def test_send_pinned_location(any_thread):
any_thread.send_pinned_location(39.9390731, 116.117273)
@pytest.mark.skip(reason="need a way to use the uploaded files from test_client.py")
def test_send_files(any_thread):
pass

View File

@@ -13,7 +13,7 @@ from fbchat._session import (
) )
def test_parse_server_js_define(): def test_parse_server_js_define_old():
html = """ html = """
some data;require("TimeSliceImpl").guard(function(){(require("ServerJSDefine")).handleDefines([["DTSGInitialData",[],{"token":"123"},100]]) some data;require("TimeSliceImpl").guard(function(){(require("ServerJSDefine")).handleDefines([["DTSGInitialData",[],{"token":"123"},100]])
@@ -29,6 +29,20 @@ def test_parse_server_js_define():
} }
def test_parse_server_js_define_new():
html = """
some data;require("TimeSliceImpl").guard(function(){new (require("ServerJS"))().handle({"define":[["DTSGInitialData",[],{"token":""},100]],"require":[...]});}, "ServerJS define", {"root":true})();
more data
<script><script>require("TimeSliceImpl").guard(function(){var s=new (require("ServerJS"))();s.handle({"define":[["DTSGInitData",[],{"token":"","async_get_token":""},3333]],"require":[...]});require("Run").onAfterLoad(function(){s.cleanup(require("TimeSliceImpl"))});}, "ServerJS define", {"root":true})();</script>
other irrelevant data
"""
define = parse_server_js_define(html)
assert define == {
"DTSGInitialData": {"token": ""},
"DTSGInitData": {"async_get_token": "", "token": ""},
}
def test_parse_server_js_define_error(): def test_parse_server_js_define_error():
with pytest.raises(ParseError, match="Could not find any"): with pytest.raises(ParseError, match="Could not find any"):
parse_server_js_define("") parse_server_js_define("")

View File

@@ -68,6 +68,17 @@ def test_get_jsmods_require():
} }
def test_get_jsmods_require_version_specifier():
data = [
["DimensionTracking@1234"],
["CavalryLoggerImpl@2345", "startInstrumentation", [], []],
]
assert get_jsmods_require(data) == {
"DimensionTracking": [],
"CavalryLoggerImpl.startInstrumentation": [],
}
def test_get_jsmods_require_get_image_url(): def test_get_jsmods_require_get_image_url():
data = [ data = [
[ [