From 39eafa5a3e2c3e176d181164d60b95b1c160bc32 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Fri, 26 May 2017 18:48:37 +0200 Subject: [PATCH] Fixed examples, added `changeNickname` and `changeThreadEmoji`, changed `changeGroupTitle` back to `changeThreadTitle` I also removed the parameter `set_default_events` from __init__, since it's not really necessary Also added testing of examples and simple testing of listen functions --- examples/echobot.py | 6 +- examples/keepbot.py | 22 ++++---- examples/removebot.py | 10 ++-- fbchat/client.py | 127 +++++++++++++++++++++++++++++------------- fbchat/utils.py | 2 + tests.py | 93 +++++++++++++++++++++---------- 6 files changed, 173 insertions(+), 87 deletions(-) diff --git a/examples/echobot.py b/examples/echobot.py index ff7437b..0b3e9c0 100644 --- a/examples/echobot.py +++ b/examples/echobot.py @@ -4,15 +4,15 @@ from fbchat import log, Client # Subclass fbchat.Client and override required methods class EchoBot(Client): - def onMessage(self, mid, author_id, message, thread_id, thread_type, ts, metadata, msg): + def onMessage(self, author_id, message, thread_id, thread_type, **kwargs): self.markAsDelivered(author_id, thread_id) self.markAsRead(author_id) - log.info("Message from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, message))) + log.info("Message from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, message)) # If you're not the author, echo if author_id != self.uid: self.sendMessage(message, thread_id=thread_id, thread_type=thread_type) client = EchoBot("", "") -client.listen() \ No newline at end of file +client.listen() diff --git a/examples/keepbot.py b/examples/keepbot.py index aa69cfb..702216c 100644 --- a/examples/keepbot.py +++ b/examples/keepbot.py @@ -18,39 +18,37 @@ old_nicknames = { } class KeepBot(Client): - def onColorChange(self, mid, author_id, new_color, thread_id, thread_type, ts, metadata, msg): + def onColorChange(self, author_id, new_color, thread_id, thread_type, **kwargs): if old_thread_id == thread_id and old_color != new_color: log.info("{} changed the thread color. It will be changed back".format(author_id)) self.changeThreadColor(old_color, thread_id=thread_id) - def onEmojiChange(self, mid, author_id, new_emoji, thread_id, thread_type, ts, metadata, msg): + def onEmojiChange(self, author_id, new_emoji, thread_id, thread_type, **kwargs): if old_thread_id == thread_id and new_emoji != old_emoji: log.info("{} changed the thread emoji. It will be changed back".format(author_id)) - # Not currently possible in `fbchat` - # self.changeThreadEmoji(old_emoji, thread_id=thread_id) + self.changeThreadEmoji(old_emoji, thread_id=thread_id) - def onPeopleAdded(self, added_ids, author_id, thread_id, msg): + def onPeopleAdded(self, added_ids, author_id, thread_id, **kwargs): if old_thread_id == thread_id and author_id != self.uid: log.info("{} got added. They will be removed".format(added_ids)) for added_id in added_ids: self.removeUserFromGroup(added_id, thread_id=thread_id) - def onPersonRemoved(self, removed_id, author_id, thread_id, msg): + def onPersonRemoved(self, removed_id, author_id, thread_id, **kwargs): # No point in trying to add ourself if old_thread_id == thread_id and removed_id != self.uid and author_id != self.uid: log.info("{} got removed. They will be re-added".format(removed_id)) self.addUsersToGroup(removed_id, thread_id=thread_id) - def onTitleChange(self, mid, author_id, new_title, thread_id, thread_type, ts, metadata, msg): + def onTitleChange(self, author_id, new_title, thread_id, thread_type, **kwargs): if old_thread_id == thread_id and old_title != new_title: log.info("{} changed the thread title. It will be changed back".format(author_id)) - self.changeGroupTitle(old_title, thread_id=thread_id) + self.changeThreadTitle(old_title, thread_id=thread_id, thread_type=thread_type) - def onNicknameChange(self, mid, author_id, changed_for, new_nickname, thread_id, thread_type, ts, metadata, msg): - if old_thread_id == thread_id and changed_for in old_nicknames: + def onNicknameChange(self, author_id, changed_for, new_nickname, thread_id, thread_type, **kwargs): + if old_thread_id == thread_id and changed_for in old_nicknames and old_nicknames[changed_for] != new_nickname: log.info("{} changed {}'s' nickname. It will be changed back".format(author_id, changed_for)) - # Not currently possible in `fbchat` - # self.changeNickname(old_nicknames[changed_for], changed_for, thread_id=thread_id, thread_type=thread_type) + self.changeNickname(old_nicknames[changed_for], changed_for, thread_id=thread_id, thread_type=thread_type) client = KeepBot("", "") client.listen() diff --git a/examples/removebot.py b/examples/removebot.py index ecdc041..80d1601 100644 --- a/examples/removebot.py +++ b/examples/removebot.py @@ -4,13 +4,13 @@ from fbchat import log, Client from fbchat.models import * class RemoveBot(Client): - def onMessage(self, mid, author_id, message, thread_id, thread_type, ts, metadata, msg): + def onMessage(self, author_id, message, thread_id, thread_type, **kwargs): # We can only kick people from group chats, so no need to try if it's a user chat if message == 'Remove me!' and thread_type == ThreadType.GROUP: - log.info("{} will be removed from {}".format(author_id, thread_id))) - self.removeUserFromGroup(user_id, thread_id=thread_id) + log.info("{} will be removed from {}".format(author_id, thread_id)) + self.removeUserFromGroup(author_id, thread_id=thread_id) else: - log.info("Message from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, message))) + log.info("Message from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, message)) client = RemoveBot("", "") -client.listen() \ No newline at end of file +client.listen() diff --git a/fbchat/client.py b/fbchat/client.py index 63dc1e7..a8b17bd 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -35,7 +35,7 @@ class Client(object): """ def __init__(self, email, password, debug=False, info_log=False, user_agent=None, max_retries=5, - session_cookies=None, logging_level=logging.INFO, set_default_events=True): + session_cookies=None, logging_level=logging.INFO): """Initializes and logs in the client :param email: Facebook `email`, `id` or `phone number` @@ -44,11 +44,9 @@ class Client(object): :param max_retries: Maximum number of times to retry login :param session_cookies: Cookies from a previous session (Will default to login if these are invalid) :param logging_level: Configures the `logging level `_. Defaults to `INFO` - :param set_default_events: Specifies whether the default `logging.info` events should be initialized :type max_retries: int :type session_cookies: dict :type logging_level: int - :type set_default_events: bool :raises: Exception on failed login """ @@ -62,7 +60,6 @@ class Client(object): self.default_thread_id = None self.default_thread_type = None self.threads = [] - self.set_default_events = set_default_events self._setupEventHooks() self._setupOldEventHooks() @@ -94,10 +91,7 @@ class Client(object): def _setEventHook(self, event_name, *functions): if not hasattr(type(self), event_name): - if self.set_default_events: - eventhook = EventHook(*functions) - else: - eventhook = EventHook() + eventhook = EventHook(*functions) setattr(self, event_name, eventhook) def _setupEventHooks(self): @@ -138,16 +132,18 @@ class Client(object): log.info("Marked messages as seen in threads {} at {}s".format([(x[0], x[1].name) for x in threads], seen_ts/1000))) - self._setEventHook('onPeopleAdded', lambda added_ids, author_id, thread_id, msg:\ + self._setEventHook('onPeopleAdded', lambda mid, added_ids, author_id, thread_id, ts, msg:\ log.info("{} added: {}".format(author_id, ', '.join(added_ids)))) - self._setEventHook('onPersonRemoved', lambda removed_id, author_id, thread_id, msg:\ + self._setEventHook('onPersonRemoved', lambda mid, removed_id, author_id, thread_id, ts, msg:\ log.info("{} removed: {}".format(author_id, removed_id))) self._setEventHook('onFriendRequest', lambda from_id, msg: log.info("Friend request from {}".format(from_id))) self._setEventHook('onInbox', lambda unseen, unread, recent_unread, msg: log.info('Inbox event: {}, {}, {}'.format(unseen, unread, recent_unread))) + self._setEventHook('onQprimer', lambda made, msg: None) + self._setEventHook('onUnknownMesssageType', lambda msg: log.debug('Unknown message received: {}'.format(msg))) @@ -447,12 +443,9 @@ class Client(object): """ Safely logs out the client - .. todo:: - Possibly check return parameter with _checkRequest, and the write documentation about the return - :param timeout: See `requests timeout `_ - :return: - :rtype: + :return: True if the action was successful + :rtype: bool """ data = { 'ref': "mb", @@ -466,7 +459,8 @@ class Client(object): self._session = requests.session() self.req_counter = 1 self.seq = "0" - return r + + return r.ok @deprecated(deprecated_in='0.10.2', removed_in='0.15.0', details='Use setDefaultThread instead') def setDefaultRecipient(self, recipient_id, is_user=True): @@ -928,13 +922,13 @@ class Client(object): image_id = self._uploadImage(image_path, open(image_path, 'rb'), mimetype) return self.sendImage(image_id=image_id, message=message, thread_id=thread_id, thread_type=thread_type) - def addUsersToGroup(self, user_ids, thread_id=None): + def addUsersToGroup(self, *user_ids, thread_id=None): """ Adds users to a group. :param user_ids: User ids to add :param thread_id: Group ID to add people to. See :ref:`intro_thread_id` - :type user_ids: list + :type user_ids: list of positional arguments :return: :ref:`Message ID ` of the sent "message" """ thread_id, thread_type = self._getThread(thread_id, None) @@ -986,29 +980,61 @@ class Client(object): def remove_user_from_chat(self, threadID, userID): return self.removeUserFromGroup(userID, thread_id=threadID) - @deprecated(deprecated_in='0.10.2', removed_in='0.15.0', details='Use changeGroupTitle() instead') - def changeThreadTitle(self, threadID, newTitle): - return self.changeGroupTitle(newTitle, thread_id=threadID) - - def changeGroupTitle(self, title, thread_id=None): + def changeThreadTitle(self, title, thread_id=None, thread_type=ThreadType.USER): """ - Changes title of a group conversation. - - .. todo:: - Check whether this can work on group threads, and if it does, change it (back) to changeThreadTitle + Changes title of a thread :param title: New group chat title :param thread_id: Group ID to change title of. See :ref:`intro_thread_id` - :return: :ref:`Message ID ` of the sent "message" + :param thread_type: See :ref:`intro_thread_type` + :type thread_type: models.ThreadType + :return: True if the action was successful + :rtype: bool """ - thread_id, thread_type = self._getThread(thread_id, None) - data = self._getSendData(thread_id, ThreadType.GROUP) + # For backwards compatability. Previously `thread_id` and `title` were swapped around + try: + int(thread_id) + except ValueError: + deprecation('changeThreadTitle(threadID, newTitle)', deprecated_in='0.10.2', removed_in='0.15.0', details='Use changeThreadTitle(title, thread_id, thread_type) instead') + title, thread_id, thread_type = thread_id, title, ThreadType.GROUP - data['action_type'] = 'ma-type:log-message' - data['log_message_data[name]'] = title - data['log_message_type'] = 'log:thread-name' + thread_id, thread_type = self._getThread(thread_id, thread_type) - return self._doSendRequest(data) + if thread_type == ThreadType.USER: + # The thread is a user, so we change the user's nickname + return self.changeNickname(title, thread_id, thread_id=thread_id, thread_type=thread_type) + else: + data = self._getSendData(thread_id, thread_type) + + data['action_type'] = 'ma-type:log-message' + data['log_message_data[name]'] = title + data['log_message_type'] = 'log:thread-name' + + return False if self._doSendRequest(data) is None else True + + def changeNickname(self, nickname, user_id, thread_id=None, thread_type=ThreadType.USER): + """ + Changes the nickname of a user in a thread + + :param nickname: New nickname + :param user_id: User that will have their nickname changed + :param thread_id: User/Group ID to change color of. See :ref:`intro_thread_id` + :param thread_type: See :ref:`intro_thread_type` + :type thread_type: models.ThreadType + :return: True if the action was successful + :rtype: bool + """ + thread_id, thread_type = self._getThread(thread_id, thread_type) + + data = { + 'nickname': nickname, + 'participant_id': user_id, + 'thread_or_other_fbid': thread_id + } + + j = self._checkRequest(self._post(ReqUrl.THREAD_NICKNAME, data)) + + return False if j is None else True def changeThreadColor(self, color, thread_id=None): """ @@ -1017,19 +1043,42 @@ class Client(object): :param color: New thread color :param thread_id: User/Group ID to change color of. See :ref:`intro_thread_id` :type color: models.ThreadColor - :return: (*bool*) True if the action was successful + :return: True if the action was successful + :rtype: bool """ thread_id, thread_type = self._getThread(thread_id, None) data = { - "color_choice": color.value, - "thread_or_other_fbid": thread_id + 'color_choice': color.value, + 'thread_or_other_fbid': thread_id } j = self._checkRequest(self._post(ReqUrl.THREAD_COLOR, data)) return False if j is None else True + def changeThreadEmoji(self, emoji, thread_id=None): + """ + Changes thread color + + Trivia: While changing the emoji, the Facebook web client actually sends multiple different requests, though only this one is required to make the change + + :param color: New thread emoji + :param thread_id: User/Group ID to change emoji of. See :ref:`intro_thread_id` + :return: True if the action was successful + :rtype: bool + """ + thread_id, thread_type = self._getThread(thread_id, None) + + data = { + 'emoji_choice': emoji, + 'thread_or_other_fbid': thread_id + } + + j = self._checkRequest(self._post(ReqUrl.THREAD_EMOJI, data)) + + return False if j is None else True + def reactToMessage(self, message_id, reaction): """ Reacts to a message. @@ -1280,7 +1329,7 @@ class Client(object): thread_id, thread_type = getThreadIdAndThreadType(metadata) self.onNicknameChange(mid=mid, author_id=author_id, changed_for=changed_for, new_nickname=new_nickname, - thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata) + thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) continue # Message delivered @@ -1347,7 +1396,7 @@ class Client(object): # Happens on every login elif mtype == "qprimer": - pass + self.onQprimer(made=m.get("made"), msg=m) # Is sent before any other message elif mtype == "deltaflow": diff --git a/fbchat/utils.py b/fbchat/utils.py index e014c40..718cf63 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -57,6 +57,8 @@ class ReqUrl(object): SAVE_DEVICE = "https://m.facebook.com/login/save-device/cancel/" CHECKPOINT = "https://m.facebook.com/login/checkpoint/" THREAD_COLOR = "https://www.facebook.com/messaging/save_thread_color/?source=thread_settings&dpr=1" + THREAD_NICKNAME = "https://www.facebook.com/messaging/save_thread_nickname/?source=thread_settings&dpr=1" + THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1" MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation" TYPING = "https://www.facebook.com/ajax/messaging/typ.php" diff --git a/tests.py b/tests.py index 397a7ca..1b83a56 100644 --- a/tests.py +++ b/tests.py @@ -7,13 +7,33 @@ import logging import unittest from getpass import getpass from sys import argv -from os import path +from os import path, chdir +from glob import glob from fbchat import Client from fbchat.models import * +import py_compile logging_level = logging.ERROR +class CustomClient(Client): + def __init__(self, *args, **kwargs): + self.got_qprimer = False + super(type(self), self).__init__(*args, **kwargs) + + def onQprimer(self, made, msg): + self.got_qprimer = True + class TestFbchat(unittest.TestCase): + def test_examples(self): + # Checks for syntax errors in the examples + chdir('examples') + for f in glob('*.txt'): + print(f) + with self.assertRaises(py_compile.PyCompileError): + py_compile.compile(f) + + chdir('..') + def test_loginFunctions(self): self.assertTrue(client.isLoggedIn()) @@ -31,13 +51,16 @@ class TestFbchat(unittest.TestCase): def test_sessions(self): global client session_cookies = client.getSession() - client = Client(email, password, session_cookies=session_cookies, logging_level=logging_level) + client = CustomClient(email, password, session_cookies=session_cookies, logging_level=logging_level) self.assertTrue(client.isLoggedIn()) def test_defaultThread(self): # setDefaultThread - client.setDefaultThread(client.uid, ThreadType.USER) + client.setDefaultThread(group_uid, ThreadType.GROUP) + self.assertTrue(client.sendMessage('test_default_recipient★')) + + client.setDefaultThread(user_uid, ThreadType.USER) self.assertTrue(client.sendMessage('test_default_recipient★')) # resetDefaultThread @@ -50,7 +73,7 @@ class TestFbchat(unittest.TestCase): self.assertGreater(len(users), 0) def test_getUsers(self): - users = client.getUsers("Mark Zuckerberg") + users = client.getUsers('Mark Zuckerberg') self.assertGreater(len(users), 0) u = users[0] @@ -66,11 +89,11 @@ class TestFbchat(unittest.TestCase): def test_sendEmoji(self): self.assertTrue(client.sendEmoji(size=EmojiSize.SMALL, thread_id=user_uid, thread_type=ThreadType.USER)) self.assertTrue(client.sendEmoji(size=EmojiSize.MEDIUM, thread_id=user_uid, thread_type=ThreadType.USER)) - self.assertTrue(client.sendEmoji("😆", EmojiSize.LARGE, user_uid, ThreadType.USER)) + self.assertTrue(client.sendEmoji('😆', EmojiSize.LARGE, user_uid, ThreadType.USER)) self.assertTrue(client.sendEmoji(size=EmojiSize.SMALL, thread_id=group_uid, thread_type=ThreadType.GROUP)) self.assertTrue(client.sendEmoji(size=EmojiSize.MEDIUM, thread_id=group_uid, thread_type=ThreadType.GROUP)) - self.assertTrue(client.sendEmoji("😆", EmojiSize.LARGE, group_uid, ThreadType.GROUP)) + self.assertTrue(client.sendEmoji('😆', EmojiSize.LARGE, group_uid, ThreadType.GROUP)) def test_sendMessage(self): self.assertIsNotNone(client.sendMessage('test_send_user★', user_uid, ThreadType.USER)) @@ -81,42 +104,54 @@ class TestFbchat(unittest.TestCase): def test_sendImages(self): image_url = 'https://cdn4.iconfinder.com/data/icons/ionicons/512/icon-image-128.png' image_local_url = path.join(path.dirname(__file__), 'tests/image.png') - #self.assertTrue(client.sendRemoteImage(image_url, 'test_send_user_images_remote', user_uid, ThreadType.USER)) + self.assertTrue(client.sendRemoteImage(image_url, 'test_send_user_images_remote★', user_uid, ThreadType.USER)) self.assertTrue(client.sendRemoteImage(image_url, 'test_send_group_images_remote★', group_uid, ThreadType.GROUP)) - # Idk why but doesnt work, payload is null - #self.assertTrue(client.sendLocalImage(image_local_url, 'test_send_group_images_local', user_uid, ThreadType.USER)) + self.assertTrue(client.sendLocalImage(image_local_url, 'test_send_group_images_local★', user_uid, ThreadType.USER)) self.assertTrue(client.sendLocalImage(image_local_url, 'test_send_group_images_local★', group_uid, ThreadType.GROUP)) def test_getThreadInfo(self): client.sendMessage('test_user_getThreadInfo★', user_uid, ThreadType.USER) - + info = client.getThreadInfo(20, user_uid, ThreadType.USER) self.assertEqual(info[0].author, 'fbid:' + client.uid) self.assertEqual(info[0].body, 'test_user_getThreadInfo★') client.sendMessage('test_group_getThreadInfo★', group_uid, ThreadType.GROUP) - + info = client.getThreadInfo(20, group_uid, ThreadType.GROUP) self.assertEqual(info[0].author, 'fbid:' + client.uid) self.assertEqual(info[0].body, 'test_group_getThreadInfo★') - def test_markAs(self): - # To be implemented (requires some form of manual watching) - pass - def test_listen(self): + client.startListening() client.doOneListen() + client.stopListening() + + self.assertTrue(client.got_qprimer) def test_getUserInfo(self): info = client.getUserInfo(4) self.assertEqual(info['name'], 'Mark Zuckerberg') def test_removeAddFromGroup(self): - self.assertTrue(client.removeUserFromGroup(user_uid, group_uid)) - self.assertTrue(client.addUsersToGroup([user_uid], group_uid)) + self.assertTrue(client.removeUserFromGroup(user_uid, thread_id=group_uid)) + self.assertTrue(client.addUsersToGroup(user_uid, thread_id=group_uid)) - def test_changeGroupTitle(self): - self.assertTrue(client.changeGroupTitle('test_changeGroupTitle★', group_uid)) + def test_changeThreadTitle(self): + self.assertTrue(client.changeThreadTitle('test_changeThreadTitle★', thread_id=group_uid, thread_type=ThreadType.GROUP)) + self.assertTrue(client.changeThreadTitle('test_changeThreadTitle★', thread_id=user_uid, thread_type=ThreadType.USER)) + + def test_changeNickname(self): + self.assertTrue(client.changeNickname('test_changeNicknameSelf★', client.uid, thread_id=user_uid, thread_type=ThreadType.USER)) + self.assertTrue(client.changeNickname('test_changeNicknameOther★', user_uid, thread_id=user_uid, thread_type=ThreadType.USER)) + self.assertTrue(client.changeNickname('test_changeNicknameSelf★', client.uid, thread_id=group_uid, thread_type=ThreadType.GROUP)) + self.assertTrue(client.changeNickname('test_changeNicknameOther★', user_uid, thread_id=group_uid, thread_type=ThreadType.GROUP)) + + def test_changeThreadEmoji(self): + self.assertTrue(client.changeThreadEmoji('😀', group_uid)) + self.assertTrue(client.changeThreadEmoji('😀', user_uid)) + self.assertTrue(client.changeThreadEmoji('😆', group_uid)) + self.assertTrue(client.changeThreadEmoji('😆', user_uid)) def test_changeThreadColor(self): self.assertTrue(client.changeThreadColor(ThreadColor.BRILLIANT_ROSE, group_uid)) @@ -125,13 +160,16 @@ class TestFbchat(unittest.TestCase): self.assertTrue(client.changeThreadColor(ThreadColor.MESSENGER_BLUE, user_uid)) def test_reactToMessage(self): - mid = client.sendMessage('react_to_message', user_uid, ThreadType.USER) + mid = client.sendMessage('test_reactToMessage★', user_uid, ThreadType.USER) self.assertTrue(client.reactToMessage(mid, MessageReaction.LOVE)) - + mid = client.sendMessage('test_reactToMessage★', group_uid, ThreadType.GROUP) + self.assertTrue(client.reactToMessage(mid, MessageReaction.LOVE)) + def test_setTypingStatus(self): + self.assertTrue(client.sendMessage('Hi', thread_id=user_uid, thread_type=ThreadType.USER)) self.assertTrue(client.setTypingStatus(TypingStatus.TYPING, thread_id=user_uid, thread_type=ThreadType.USER)) - self.assertTrue(client.setTypingStatus(TypingStatus.STOPPED, thread_id=user_uid, thread_type=ThreadType.USER)) self.assertTrue(client.setTypingStatus(TypingStatus.TYPING, thread_id=group_uid, thread_type=ThreadType.GROUP)) + self.assertTrue(client.setTypingStatus(TypingStatus.STOPPED, thread_id=user_uid, thread_type=ThreadType.USER)) self.assertTrue(client.setTypingStatus(TypingStatus.STOPPED, thread_id=group_uid, thread_type=ThreadType.GROUP)) @@ -143,8 +181,8 @@ def start_test(param_client, param_group_uid, param_user_uid, tests=[]): client = param_client group_uid = param_group_uid user_uid = param_user_uid - - tests = ['test_' + test for test in tests] + + tests = ['test_' + test if 'test_' != test[:5] else test for test in tests] if len(tests) == 0: suite = unittest.TestLoader().loadTestsFromTestCase(TestFbchat) @@ -175,10 +213,9 @@ if __name__ == '__main__': password = getpass() group_uid = input('Please enter a group thread id (To test group functionality): ') user_uid = input('Please enter a user thread id (To test kicking/adding functionality): ') - + print('Logging in...') - client = Client(email, password, logging_level=logging_level) - + client = CustomClient(email, password, logging_level=logging_level) + # Warning! Taking user input directly like this could be dangerous! Use only for testing purposes! start_test(client, group_uid, user_uid, argv[1:]) -