Compare commits

...

852 Commits

Author SHA1 Message Date
Mads Marquart
ef95aed208 Bump version: 1.9.6 -> 2.0.0a1 2020-03-11 14:25:33 +01:00
Mads Marquart
8aaed0c76a Remove bump2version 2020-03-11 13:20:19 +01:00
Mads Marquart
6dbcb8cc47 Don't mandate a specific way to handle listening events (for now) 2020-03-11 12:37:34 +01:00
Mads Marquart
6660fd099d Fix uploading, and move it to Client.upload (making it public again) 2020-03-11 11:48:04 +01:00
Mads Marquart
e6ec5c5194 Redo jsmods loading and fb_dtsg retrieving 2020-03-11 11:23:39 +01:00
Mads Marquart
13e0eb7fcf Fix typo 2020-03-11 10:25:40 +01:00
Mads Marquart
7bdacb91ba Add shortcuts to easily delete threads/messages 2020-02-05 18:45:20 +01:00
Mads Marquart
94c985cb10 Fix ThreadABC.fetch_messages ordering, and clean up a few docstrings 2020-02-05 17:22:06 +01:00
Mads Marquart
0f4ee33d2a Fix failing test for NotLoggedIn 2020-02-05 15:29:57 +01:00
Mads Marquart
4df1d5e0d4 Merge branch 'mqtt-improve' 2020-02-05 14:29:51 +01:00
Mads Marquart
085bbba302 Improve listening usability
Add Listener.register and Listener.run
2020-02-05 14:27:08 +01:00
Mads Marquart
ae2bb41509 Small listening fixes
- If an error is raised in fetch_sequence_id, don't swallow it!
2020-02-05 13:50:01 +01:00
Mads Marquart
9c03c1035b Allow initializing the MQTT Listener without making external requests 2020-01-25 15:07:40 +01:00
Mads Marquart
987993701f Fix NotLoggedIn 2020-01-25 15:07:04 +01:00
Mads Marquart
f8e110f180 Handle connecting in Listener.listen 2020-01-25 14:50:15 +01:00
Mads Marquart
2da8369c70 Refactor and improve MQTT listener a bit 2020-01-25 14:39:58 +01:00
Mads Marquart
588c93467e Merge branch 'v1' 2020-01-25 14:03:53 +01:00
Mads Marquart
01effb34b4 Add Session.user in favor of Session.user_id 2020-01-25 11:42:32 +01:00
Mads Marquart
2c8dfc02c2 Add ThreadABC copy helper 2020-01-25 11:41:52 +01:00
Mads Marquart
064707ac23 Add error handling for when the listener has been logged out 2020-01-24 21:19:58 +01:00
Mads Marquart
eaacaaba8d Fix yet another typo 2020-01-24 21:13:14 +01:00
Mads Marquart
2cb43ff0b0 Fix typo 2020-01-23 16:56:15 +01:00
Mads Marquart
16081fbb19 Clean up utility functions 2020-01-23 16:19:09 +01:00
Mads Marquart
4015bed474 Move ThreadLocation, ActiveStatus and Image to _models/ folder 2020-01-23 15:19:41 +01:00
Mads Marquart
c71c1d37c2 Small cleanup of event parsing 2020-01-23 14:56:28 +01:00
Mads Marquart
1776c3aa45 Add test for fixup_module_metadata 2020-01-23 14:34:12 +01:00
Mads Marquart
a1fc235327 Refactor models file structure 2020-01-23 14:22:36 +01:00
Mads Marquart
2aea401c79 Refactor threads file structure 2020-01-23 14:06:26 +01:00
Mads Marquart
c83836ceed Rename _core.py -> _common.py 2020-01-23 14:06:26 +01:00
Mads Marquart
3efeffe6dd Refactor events file structure 2020-01-23 14:06:26 +01:00
Mads Marquart
45a71fd1a3 Add inline examples 2020-01-23 12:07:40 +01:00
Mads Marquart
0d139cee73 Fix frozen MQTT instances 2020-01-23 11:26:30 +01:00
Mads Marquart
89f90ef849 Make all models frozen, and sessions hashable 2020-01-23 10:18:33 +01:00
Mads Marquart
7019124d1f Merge pull request #518 from carpedm20/fix-documentation
Improve documentation
2020-01-23 00:15:07 +01:00
Mads Marquart
0fd58c52ea Small doc fixes 2020-01-22 23:59:47 +01:00
Mads Marquart
8277b22c5c Small fixes / optimizations 2020-01-22 23:56:39 +01:00
Mads Marquart
55ef9979c3 Clean up FAQ 2020-01-22 23:14:31 +01:00
Mads Marquart
3d3b0f9e91 Remove todo page and testing page in documentation 2020-01-22 23:00:45 +01:00
Mads Marquart
05375d9b11 Rewrite introduction documentation 2020-01-22 22:53:13 +01:00
Mads Marquart
66fdd91953 Disable fixup_module_metadata when running Sphinx 2020-01-22 19:21:10 +01:00
Mads Marquart
9fc9aeac08 Fix README.rst for PyPI usage 2020-01-22 15:02:33 +01:00
Mads Marquart
935947f212 Fix a few spelling errors 2020-01-22 03:48:03 +01:00
Mads Marquart
41f367a61b Use sphinx-autodoc-typehints 2020-01-22 03:29:26 +01:00
Mads Marquart
03cc95e755 Update CONTRIBUTING.rst and README.rst 2020-01-22 03:22:19 +01:00
Mads Marquart
b6fd7e2cf2 Remove unnecessary dunder package attributes 2020-01-22 02:41:39 +01:00
Mads Marquart
1526266bf3 Export MessageData model 2020-01-22 01:43:11 +01:00
Mads Marquart
e666073b18 Fix API docs 2020-01-22 01:43:11 +01:00
Mads Marquart
2644aa9b7a Add type hints, and clean up Client a bit 2020-01-22 01:43:04 +01:00
Mads Marquart
701fe8ffc8 Remove old, broken tests 2020-01-21 23:05:57 +01:00
Mads Marquart
6117049489 Fix event parsing tests 2020-01-21 22:55:12 +01:00
Mads Marquart
6344038bac Encode more assumptions in event parsing 2020-01-21 22:17:01 +01:00
Mads Marquart
316ffe5a52 Merge branch 'v1' 2020-01-21 22:04:22 +01:00
Mads Marquart
f7788a47bc Verify list of all sendable reactions 2020-01-21 19:54:03 +01:00
Mads Marquart
a4afc39c13 Merge pull request #515 from carpedm20/refactor-listen-parsing
Refactor listen parsing
2020-01-21 19:53:11 +01:00
Mads Marquart
b9b4d57b25 Bump version: 1.9.5 → 1.9.6 2020-01-21 19:50:57 +01:00
Mads Marquart
74a98d7eb3 Fix MessagesDelivered user parsing 2020-01-21 19:50:33 +01:00
Mads Marquart
b4618739f3 Fix MQTT errors after being offline for too long 2020-01-21 19:39:59 +01:00
Mads Marquart
9b75db898a Move listen methods out of Client and into MQTT class
Make MQTT class / `Listener` public
2020-01-21 01:29:43 +01:00
Mads Marquart
01f8578dea Add top level MQTT topic parsing to a separate file 2020-01-20 18:20:24 +01:00
Mads Marquart
0a6bf221e6 Move /t_ms delta admin text type parsing to separate file and add tests 2020-01-20 18:02:56 +01:00
Mads Marquart
4abe5659ae Move /t_ms delta class parsing to separate file and add tests 2020-01-20 18:02:55 +01:00
Mads Marquart
22c6c82c0e Disable /t_rtc MQTT topic 2020-01-20 14:54:25 +01:00
Mads Marquart
9cc286a1b0 Fix MQTT exceptions 2020-01-20 14:53:53 +01:00
Mads Marquart
19c875c18a Bump version: 1.9.4 → 1.9.5 2020-01-20 09:32:30 +01:00
Mateusz Soszyński
12bbc0058c Add onPendingMessage (#512) 2020-01-20 09:28:41 +01:00
Mads Marquart
0696ff9f4b Move ClientPayload parsing to separate file and add tests 2020-01-16 17:06:15 +01:00
Mads Marquart
e735823d37 Add initial common/core listen events 2020-01-16 16:02:58 +01:00
Mads Marquart
dbc88bc4ed Fix merge error, fix listening and clean up 2020-01-16 11:32:21 +01:00
Mads Marquart
d2f8acb68f Merge branch 'v1' 2020-01-15 16:37:08 +01:00
Mads Marquart
8b70fe8bfd Fix module names to hide implementation details
E.g. now fbchat.PleaseRefresh.__module__ == 'fbchat'
2020-01-15 16:08:59 +01:00
Mads Marquart
9228ac698d Merge pull request #510 from carpedm20/improve-exceptions
Improve exceptions
2020-01-15 16:07:37 +01:00
Mads Marquart
c0425193d0 Various error improvements 2020-01-15 15:15:50 +01:00
Mads Marquart
28791b2118 Add ExternalError.description and GraphQLError.debug_info 2020-01-15 14:03:35 +01:00
Mads Marquart
e25f53d9a9 Wrap requests exceptions 2020-01-15 12:19:28 +01:00
Mads Marquart
8f25a3bae8 Fix exceptions in tests 2020-01-15 11:17:51 +01:00
Mads Marquart
3cdd646c37 Move error handling functions to _exception 2020-01-15 11:05:59 +01:00
Mads Marquart
3445eccc32 Initial redo of exceptions 2020-01-15 10:49:16 +01:00
Mads Marquart
9c81806b95 Bump version: 1.9.3 → 1.9.4 2020-01-14 23:29:58 +01:00
Mads Marquart
45303005b8 Fix onFriendRequest 2020-01-14 23:27:50 +01:00
Mads Marquart
656281eacb Merge pull request #507 from carpedm20/refactor-limits
Refactor method limits
2020-01-14 22:13:58 +01:00
Mads Marquart
2b45fdbc8a Make Client.search_for_X more forwards compatible 2020-01-14 22:10:12 +01:00
Mads Marquart
22dcf6d69a Update ThreadABC.fetch_messages 2020-01-14 21:53:55 +01:00
Mads Marquart
60cce0d112 Refactor Client.fetch_thread_list to return an iterable 2020-01-14 21:28:54 +01:00
Mads Marquart
117433da8a Improve image fetching in ThreadABC 2020-01-14 18:47:14 +01:00
Mads Marquart
55182e21b6 Improve message searching in Client 2020-01-13 17:06:59 +01:00
Mads Marquart
e76c6179fb Improve message searching in ThreadABC 2020-01-13 16:37:28 +01:00
Mads Marquart
e4f2c6c403 Add get_limits helper 2020-01-13 15:11:20 +01:00
Mads Marquart
3c35770eca Fix sending messages 2020-01-13 12:47:21 +01:00
Mads Marquart
7c7ac1f1f6 Clean up User parsing 2020-01-13 11:39:09 +01:00
Mads Marquart
da18111ed0 Add ThreadABC._parse_participants 2020-01-13 11:39:09 +01:00
Mads Marquart
5e09cb9cab Remove composite methods in Client
- graphql_requests
- graphql_request
- fetch_threads
- fetch_all_users_from_threads
- fetch_user_info
- fetch_page_info
- fetch_group_info

fetch_threads and fetch_all_users_from_threads will be replaced later by
better versions
2020-01-13 11:39:09 +01:00
Mads Marquart
3662fbd038 Rename Client.fetch_all_users -> .fetch_users, and document it better 2020-01-13 11:39:09 +01:00
Mads Marquart
281ef4714f Message parsing fixes 2020-01-13 10:01:24 +01:00
Mads Marquart
26f99d983e Refactor polls and poll options 2020-01-09 22:03:15 +01:00
Mads Marquart
9dd760223e Merge pull request #502 from carpedm20/remove-enums
Remove extraneous enums
2020-01-09 21:12:53 +01:00
Mads Marquart
9f1c9c9697 Remove _core.Enum and aenum dependency 2020-01-09 21:00:35 +01:00
Mads Marquart
c81e509eb0 Remove TypingStatus 2020-01-09 21:00:35 +01:00
Mads Marquart
8b6d9b16c6 Remove ThreadColor
Replaced with raw color values. In the future, we should probably
investigate using "themes"
2020-01-09 21:00:34 +01:00
Mads Marquart
3341f4a45c Remove MessageReaction 2020-01-09 19:51:06 +01:00
Mads Marquart
b00f748647 Remove msg parameter from Client.on_x methods 2020-01-09 18:25:02 +01:00
Mads Marquart
f2bf3756db Standardize json parsing 2020-01-09 18:19:25 +01:00
Mads Marquart
c98fa40c42 Fix lint 2020-01-09 18:00:03 +01:00
Mads Marquart
333c879192 Merge pull request #501 from carpedm20/split-models
Split User, Group, Page, Plan and Message classes
2020-01-09 17:58:31 +01:00
Mads Marquart
e53d10fd85 Make .offset and .length on Mention required 2020-01-09 17:50:19 +01:00
Mads Marquart
5214a2aed2 Make .author and .created_at on MessageData required 2020-01-09 17:50:19 +01:00
Mads Marquart
12c2059812 Split Message into Message/MessageData 2020-01-09 17:50:18 +01:00
Mads Marquart
a1b3fd3ffa Refactor message sending 2020-01-09 17:21:07 +01:00
Mads Marquart
6b39e58eb8 Improve login error handling 2020-01-09 17:05:09 +01:00
Mads Marquart
6d6f779d26 Move plan actions into Plan 2020-01-09 15:17:51 +01:00
Mads Marquart
483fdf43dc Split Plan into Plan/PlanData, and add Plan.session 2020-01-09 15:13:37 +01:00
Mads Marquart
e039e88f80 Be more explicit in UserData/PageData parsing
Allows us to make some fields required (aka. not None)
2020-01-09 14:52:29 +01:00
Mads Marquart
2459a0251a Split Group into Group/GroupData 2020-01-09 14:09:44 +01:00
Mads Marquart
c7ee45aaca Split Page into Page/PageData 2020-01-09 14:09:33 +01:00
Mads Marquart
22217c793c Split User into User/UserData 2020-01-09 14:09:17 +01:00
Mads Marquart
fbeee69ece Add .mypy_cache/ to gitignore 2020-01-09 13:19:10 +01:00
Mads Marquart
c79cfd21b0 Fix various errors in examples 2020-01-09 13:18:14 +01:00
Mads Marquart
deda3b433d Fix various errors
Found using mypy!
2020-01-09 13:04:04 +01:00
Mads Marquart
906e813378 Fix frame_count tests 2020-01-09 12:30:27 +01:00
Mads Marquart
a9eeacb5be Merge pull request #459 from YellowOnion/frame_count
Add Sticker.frame_count
2020-01-09 12:28:44 +01:00
Mads Marquart
b4009cc0e6 Add Sticker.frame_count attribute 2020-01-09 12:26:44 +01:00
Mads Marquart
942c3e5b70 Merge pull request #499 from carpedm20/session-in-models
Add ThreadABC helper, and move a bunch of methods out of Client
2020-01-09 11:33:45 +01:00
Mads Marquart
2ec0be9635 Remove ThreadType completely 2020-01-09 11:22:28 +01:00
Mads Marquart
d8d044f091 Merge pull request #498 from carpedm20/rename-uid
Rename .uid to .id
2020-01-09 10:58:46 +01:00
Mads Marquart
f968e583e8 Make Client.session attribute public 2020-01-09 10:55:36 +01:00
Mads Marquart
88ba9c55d2 Merge pull request #497 from carpedm20/public-session
Rename State -> Session, and make the class public.
2020-01-09 10:49:43 +01:00
Mads Marquart
6baa594538 Fix user affinity test 2020-01-09 10:49:12 +01:00
Mads Marquart
0e0fce714a Allow on_2fa_callback to be None in Session.login 2020-01-09 10:39:30 +01:00
Mads Marquart
cf24c7e8c2 Add Session and Client __repr__ implementations 2020-01-09 10:32:30 +01:00
Mads Marquart
ded6039b69 Add message-related functions to Message model 2020-01-09 01:13:27 +01:00
Mads Marquart
6b4327fa69 Add Message.session 2020-01-09 01:13:27 +01:00
Mads Marquart
53e4669fc1 Move fetch_message_info to Message 2020-01-09 01:13:27 +01:00
Mads Marquart
4dea10d5de Add thread mute settings to ThreadABC 2020-01-09 01:13:26 +01:00
Mads Marquart
bd2b39c27a Add thread actions to ThreadABC 2020-01-09 01:13:17 +01:00
Mads Marquart
e9864208ac Fix user affinity 2020-01-09 00:36:11 +01:00
Mads Marquart
f3b1d10d85 Move fetch methods to ThreadABC 2020-01-09 00:35:44 +01:00
Mads Marquart
13aa1f5e5a Move send methods to ThreadABC 2020-01-09 00:35:44 +01:00
Mads Marquart
aeca4865ae Add unfinished NewGroup helper class 2020-01-09 00:35:44 +01:00
Mads Marquart
152f20027a Add ThreadABC helper, that'll contain functions that threads can call 2020-01-09 00:35:43 +01:00
Mads Marquart
4199439e07 Remove Thread.type 2020-01-08 23:52:14 +01:00
Mads Marquart
64f55a572e Move group-related functions to Group model 2020-01-08 23:32:45 +01:00
Mads Marquart
a26554b4d6 Move user-related functions to User model 2020-01-08 23:23:19 +01:00
Mads Marquart
0531a9e482 Add session attribute to Group/User/Page/Thread 2020-01-08 23:11:39 +01:00
Mads Marquart
a5abb05ab3 Rename .uid -> .id everywhere 2020-01-08 23:09:51 +01:00
Mads Marquart
45c0a4772d Move attributes out of Thread and into User/Page/Group 2020-01-08 12:25:06 +01:00
Mads Marquart
a36ff5ee6e Make Session.user_id readonly 2020-01-08 12:24:47 +01:00
Mads Marquart
78949e8ad5 Update examples
Only `import fbchat`, and update to initialize Client using Session
2020-01-08 10:45:41 +01:00
Mads Marquart
06b7e14c31 Initialize Client using Session 2020-01-08 10:41:17 +01:00
Mads Marquart
41f1007936 Make Session public 2020-01-08 10:33:25 +01:00
Mads Marquart
092573fcbb Rename State -> Session 2020-01-08 10:16:40 +01:00
Mads Marquart
881aa9adce Bump version: 1.9.2 → 1.9.3 2020-01-08 09:38:18 +01:00
Mads Marquart
4714be5697 Fix MQTT JSON decoding 2020-01-08 09:35:26 +01:00
Mads Marquart
cb7f4a72d7 Bump version: 1.9.1 → 1.9.2 2020-01-08 08:47:16 +01:00
Mads Marquart
fb63ff0db8 Fix cookie header extraction
Only worked when the cookies were loaded from file, hence the reason I
didn't spot it the first time. Thanks to @gave92 for the suggestion.

Fixes #495
2020-01-08 08:46:22 +01:00
Mads Marquart
c5f447e20b Bump version: 1.9.0 → 1.9.1 2020-01-06 13:23:39 +01:00
Mads Marquart
b4d3769fd5 Fix MQTT error handling
- Fix "Out of memory" errors
- Fix typo
2020-01-06 13:14:07 +01:00
Mads Marquart
b199d597b2 Bump version: 1.8.3 → 1.9.0 2020-01-06 10:57:19 +01:00
Mads Marquart
debfb37a47 Merge pull request #494 from carpedm20/websocket-mqtt-support
Add MQTT over WebSockets support
2020-01-06 10:51:20 +01:00
Mads Marquart
67fd6ffdf6 Better document MQTT topics 2020-01-06 10:34:39 +01:00
Mads Marquart
e57265016e Skip NoOp events 2020-01-06 10:27:40 +01:00
Mads Marquart
cf4c22898c Add undocumented _onSeen callback
Mostly just to slowly document unknown events
2020-01-06 10:27:11 +01:00
Mads Marquart
3bb99541e7 Improve MQTT connection error reporting 2020-01-05 23:44:19 +01:00
Mads Marquart
8c367af0ff Fix Python 2.7 errors 2020-01-05 20:52:50 +01:00
Mads Marquart
e1c5e5e417 Merge pull request #492 from OneBlue/mark-as-read-timestamp
Add a optional timestamp parameter to mark_as_read and mark_as_unread
2020-01-05 20:51:40 +01:00
Mads Marquart
bc1e3edf17 Small fixes
Handle more errors, and fix Client.stopListening
2020-01-05 20:29:44 +01:00
Mads Marquart
e488f4a7da Fix typing status parsing
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2020-01-05 19:57:53 +01:00
Mads Marquart
afad38d8e1 Fix chat timestamp parsing 2020-01-05 19:57:53 +01:00
Mads Marquart
e9804d4184 Fix message parsing 2020-01-05 19:57:53 +01:00
Mads Marquart
a1b80a7abb Replace pull channel with MQTT setup 2020-01-05 19:57:53 +01:00
Mads Marquart
803bfa7084 Add proper MQTT error handling 2020-01-05 19:57:53 +01:00
Mads Marquart
d1cb866b44 Refactor MQTT listening 2020-01-05 19:57:52 +01:00
Mads Marquart
a298e0cf16 Refactor MQTT to do proper reconnecting 2020-01-05 14:56:01 +01:00
Mads Marquart
766b0125fb Refactor MQTT connecting, add sync token support 2020-01-05 00:31:58 +01:00
Mads Marquart
998fa43fb2 Refactor MQTT connecting 2020-01-04 23:18:20 +01:00
Mads Marquart
ecc6edac5a Fix message receiving in MQTT 2020-01-04 16:23:51 +01:00
Mads Marquart
ea518ba4c9 Add initial MQTT helper 2020-01-04 16:23:35 +01:00
Blue
49d5891bf5 Use datetime instead of raw timestamp 2020-01-01 23:24:12 +01:00
Blue
5fd7ef5191 Add a optional timestamp parameter to mark_as_read and mark_as_unread 2019-12-26 17:27:15 +01:00
Mads Marquart
ffdf4222bf Split ._parseMessage to reduce indentation 2019-12-15 16:24:17 +01:00
Mads Marquart
a97ef67411 Backport e348425 2019-12-15 15:26:53 +01:00
Mads Marquart
aea4fea5a2 Set black target version explicitly 2019-12-12 09:56:26 +01:00
Mads Marquart
6c82e4d966 Merge pull request #489 from carpedm20/model-changes
Model changes
2019-12-11 16:32:28 +01:00
Mads Marquart
d1fbf0ba0a Clean up doc references 2019-12-11 16:20:38 +01:00
Mads Marquart
aaf26691d6 Move Mention parsing into the class itself 2019-12-11 16:20:38 +01:00
Mads Marquart
1f96c624e7 Combine variously sized previews to a single key 2019-12-11 16:20:38 +01:00
Mads Marquart
a7b08fefe4 Use attrs on exception classes 2019-12-11 16:20:38 +01:00
Mads Marquart
91d4055545 Make models use kw_only (on Python > 3.5) 2019-12-11 16:12:14 +01:00
Mads Marquart
523c320c08 Make models use slots 2019-12-11 16:12:14 +01:00
Mads Marquart
27ae1c9f88 Stop mutating models 2019-12-11 16:12:14 +01:00
Mads Marquart
b03d0ae3b7 Allow specifying class variables in init 2019-12-11 16:12:14 +01:00
Mads Marquart
637ea97ffe Add Image model 2019-12-11 16:12:14 +01:00
Mads Marquart
074c271fb8 Fix pytest version 2019-12-11 16:11:57 +01:00
Mads Marquart
e348425204 Fix black version 2019-12-11 16:11:54 +01:00
Mads Marquart
b8f83610e7 Merge pull request #480 from carpedm20/add-unit-tests
Add unit tests
2019-10-28 11:01:39 +01:00
Mads Marquart
41a445a989 Add ShareAttachment subattachment tests 2019-10-28 10:33:45 +01:00
Mads Marquart
80c7fff571 Add file attachment tests 2019-10-28 10:33:04 +01:00
Mads Marquart
e2d98356ad Add poll tests 2019-10-27 14:40:09 +01:00
Mads Marquart
a8412ea3d8 Add plan tests 2019-10-27 14:40:09 +01:00
Mads Marquart
71177d8bf9 Add group test 2019-10-27 14:40:09 +01:00
Mads Marquart
5019aac6b7 Add page test 2019-10-27 14:40:09 +01:00
Mads Marquart
0c305f621a Add user tests 2019-10-27 14:40:09 +01:00
Mads Marquart
ef73bb27aa Add graphql tests 2019-10-27 14:40:09 +01:00
Mads Marquart
bd499c1ea2 Add message tests 2019-10-27 14:40:09 +01:00
Mads Marquart
24c4b10012 Add thread tests 2019-10-27 14:40:09 +01:00
Mads Marquart
648cbb4999 Add location tests, and fix live location expires_at parsing 2019-10-27 14:40:09 +01:00
Mads Marquart
ef5c86c427 Add quick reply tests 2019-10-27 14:40:09 +01:00
Mads Marquart
5e0b80cada Add sticker tests 2019-10-27 14:40:09 +01:00
Mads Marquart
9898e8cd19 Add attachment tests 2019-10-27 14:40:09 +01:00
Mads Marquart
77d9b25bf0 Add utility function tests 2019-10-27 14:40:08 +01:00
Mads Marquart
e757e51a4e Remove most __init__ methods 2019-10-22 20:18:14 +02:00
Mads Marquart
ce8711ba65 Enable model comparisons 2019-10-22 20:04:08 +02:00
Mads Marquart
bdd7f69a66 Fix missing pytest 4.0 features
Not really sure how many versions of pytest we should support? But then
again, we might as well for now...
2019-09-10 21:22:28 +02:00
Mads Marquart
d06ff7078a Mark existing tests as online
- Remove `offline` and `expensive` markers
2019-09-10 10:59:01 +02:00
Mads Marquart
7416c8b7fc Make test setup more strict 2019-09-10 10:59:01 +02:00
Mads Marquart
fc7cc4ca38 Fix typo 2019-09-10 10:58:28 +02:00
Mads Marquart
614e5ad4bb Use snake_case method names
Renamed:
- Message.formatMentions
- _util.digitToChar
- _util.generateMessageID
- _util.getSignatureID
- _util.generateOfflineThreadingID
- Client._markAlive

Renamed following Client methods:
- isLoggedIn
- getSession
- setSession
- _forcedFetch
- fetchThreads
- fetchAllUsersFromThreads
- fetchAllUsers
- searchForUsers
- searchForPages
- searchForGroups
- searchForThreads
- searchForMessageIDs
- searchForMessages
- _fetchInfo
- fetchUserInfo
- fetchPageInfo
- fetchGroupInfo
- fetchThreadInfo
- fetchThreadMessages
- fetchThreadList
- fetchUnread
- fetchUnseen
- fetchImageUrl
- fetchMessageInfo
- fetchPollOptions
- fetchPlanInfo
- _getPrivateData
- getPhoneNumbers
- getEmails
- getUserActiveStatus
- fetchThreadImages
- _oldMessage
- _doSendRequest
- quickReply
- _sendLocation
- sendLocation
- sendPinnedLocation
- _sendFiles
- sendRemoteFiles
- sendLocalFiles
- sendRemoteVoiceClips
- sendLocalVoiceClips
- forwardAttachment
- createGroup
- addUsersToGroup
- removeUserFromGroup
- _adminStatus
- addGroupAdmins
- removeGroupAdmins
- changeGroupApprovalMode
- _usersApproval
- acceptUsersToGroup
- denyUsersFromGroup
- _changeGroupImage
- changeGroupImageRemote
- changeGroupImageLocal
- changeThreadTitle
- changeNickname
- changeThreadColor
- changeThreadEmoji
- reactToMessage
- createPlan
- editPlan
- deletePlan
- changePlanParticipation
- createPoll
- updatePollVote
- setTypingStatus
- markAsDelivered
- _readStatus
- markAsRead
- markAsUnread
- markAsSeen
- friendConnect
- removeFriend
- blockUser
- unblockUser
- moveThreads
- deleteThreads
- markAsSpam
- deleteMessages
- muteThread
- unmuteThread
- muteThreadReactions
- unmuteThreadReactions
- muteThreadMentions
- unmuteThreadMentions
- _pullMessage
- _parseMessage
- _doOneListen
- setActiveStatus
- onLoggingIn
- on2FACode
- onLoggedIn
- onListening
- onListenError
- onMessage
- onColorChange
- onEmojiChange
- onTitleChange
- onImageChange
- onNicknameChange
- onAdminAdded
- onAdminRemoved
- onApprovalModeChange
- onMessageSeen
- onMessageDelivered
- onMarkedSeen
- onMessageUnsent
- onPeopleAdded
- onPersonRemoved
- onFriendRequest
- onInbox
- onTyping
- onGamePlayed
- onReactionAdded
- onReactionRemoved
- onBlock
- onUnblock
- onLiveLocation
- onCallStarted
- onCallEnded
- onUserJoinedCall
- onPollCreated
- onPollVoted
- onPlanCreated
- onPlanEnded
- onPlanEdited
- onPlanDeleted
- onPlanParticipation
- onQprimer
- onChatTimestamp
- onBuddylistOverlay
- onUnknownMesssageType
- onMessageError
2019-09-08 19:59:53 +02:00
Mads Marquart
8d8ef6bbc9 Merge pull request #466 from carpedm20/various-removals
Various removals
2019-09-08 18:51:20 +02:00
Mads Marquart
5aed7b0abc Remove login retrying
Unnecessary clutter, easy to implement if required by the user.
2019-09-08 18:44:46 +02:00
Mads Marquart
856c1ffe0e Remove ability to control the listening loop externally
It was probably scarcely used, and separate functionality will be
developed that makes this redundant anyhow.
2019-09-08 18:44:46 +02:00
Mads Marquart
650112a592 Remove automatic fb_dtsg refreshing
This was error prone, inefficient and wouldn't handle all error cases.
The real solution is to make some way to retry the request in the
general case (since you can alway just get logged out), and that's
probably out of scope for this project, at least right now. :/
2019-09-08 18:44:46 +02:00
Mads Marquart
b5a37e35c6 Remove FBchatUserError in favor of builtin exceptions 2019-09-08 18:44:46 +02:00
Mads Marquart
91cf4589a5 Remove ability to set a custom User-Agent
This causes issues if the User-Agent is set to resemble a mobile phone,
see #431, and besides, it's not an API surface I want / need to support.
2019-09-08 18:44:46 +02:00
Mads Marquart
4155775305 Remove ssl_verify property
Only used when debugging, and in that case, the functionality could be
implemented using private APIs.
2019-09-08 18:44:45 +02:00
Mads Marquart
7c758501fc Remove methods to set the default thread
This has been done to value explicitness over implicitness, and also
since the question of whether thread_id=None is acceptable was dependent
on mutable variables in Client.
2019-09-08 18:44:45 +02:00
Mads Marquart
c70a39c568 Remove deprecated arguments, methods, and classes 2019-09-08 18:44:06 +02:00
Mads Marquart
2e88bd49d4 Merge pull request #472 from carpedm20/use-datetime
Use datetime/timedelta objects
2019-09-08 18:41:41 +02:00
Mads Marquart
813219cd9c Bump version: 1.8.2 → 1.8.3 2019-09-08 15:59:29 +02:00
Asiel Díaz Benítez
bb1f7d9294 Fix mimetypes.guess_type (#471)
`mimetypes.guess_type` fails if the url is something like `http://example.com/file.zip?u=10`.

Backported from 6bffb66
2019-09-08 15:58:34 +02:00
Asiel Díaz Benítez
6bffb66b5e Fix mimetypes.guess_type (#471)
`mimetypes.guess_type` fails if the url is something like `http://example.com/file.zip?u=10`.
2019-09-08 15:56:27 +02:00
Mads Marquart
72ab8695f1 Make ts a datetime, and rename to at in all onX methods 2019-09-08 15:24:58 +02:00
Mads Marquart
47bdb84957 Make seen_ts a datetime, and rename to seen_at in onX methods
- onMessageSeen
- onMarkedSeen
2019-09-08 15:24:58 +02:00
Mads Marquart
24cf4047b7 Convert durations to timedeltas
On:
- AudioAttachment.duration
- VideoAttachment.duration
- Client.onCallEnded call_duration argument
- Client.muteThread mute_time argument
2019-09-08 15:24:58 +02:00
Mads Marquart
2e53963398 Make LiveLocationAttachment.expires_at a datetime object
Renamed from .expiration_time
2019-09-08 15:24:58 +02:00
Mads Marquart
61842b199f Make ActiveStatus.last_active a datetime object 2019-09-08 15:24:58 +02:00
Mads Marquart
aef64e5c29 Make Message.timestamp a datetime object, and rename to .created_at 2019-09-08 15:24:58 +02:00
Mads Marquart
6d13937c4a Make Plan.time a datetime object 2019-09-08 15:24:57 +02:00
Mads Marquart
4b34a063e8 Rename Thread.last_message_timestamp to .last_active, and use datetimes 2019-09-08 15:20:31 +02:00
Mads Marquart
ba088d45a7 Make Client fetching methods use datetime objects
On:
- Client.fetchThreads after and before arguments
- Client.fetchThreadMessages before argument
- Client.fetchThreadList before argument
2019-09-08 15:20:31 +02:00
Mads Marquart
d12f9fd645 Add datetime helper functions 2019-09-08 15:20:30 +02:00
Mads Marquart
a6a3768a38 Fix _util.now() usage in Client 2019-09-08 13:15:11 +02:00
Mads Marquart
3d28c958d3 Bump version: 1.8.1 → 1.8.2 2019-09-05 20:07:44 +02:00
Marco Gavelli
6b68916d74 Fix Python 2 only issue (str.split does not take keyword parameters)
Fixes #469
2019-09-05 20:02:51 +02:00
Mads Marquart
8052b818de Small fixes 2019-08-28 23:03:31 +02:00
Mads Marquart
da4ed73ec6 Remove models.py 2019-08-28 22:59:22 +02:00
Mads Marquart
62c9512734 Clean up imports 2019-08-28 22:44:42 +02:00
Mads Marquart
d3a0ffc478 Fix logging
- Following advice here: https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library
- Renamed the logger: client -> fbchat
- Remove logging_level init parameter from Client
- Use print instead of log.info in examples
2019-08-28 22:27:29 +02:00
Mads Marquart
d84ad487ee Merge pull request #465 from carpedm20/drop-python-2
Drop Python 2 support
2019-08-28 22:01:10 +02:00
Mads Marquart
01b80b300e Remove explicit new style class declarations 2019-08-28 21:57:50 +02:00
Mads Marquart
66505f8f41 Remove redundant encoding specifiers and __future__ imports 2019-08-28 21:57:46 +02:00
Mads Marquart
75378bb709 Remove Python 2 specific imports 2019-08-28 21:37:16 +02:00
Mads Marquart
6fb6e707ba Remove six dependency 2019-08-28 21:26:59 +02:00
Mads Marquart
330473a092 Update PyPI classifiers and required python version 2019-08-28 21:24:59 +02:00
Mads Marquart
5ee93b760a Update badges
- Update version numbers
- Use badgen.net instead of shields.io
- Remove badges from the docs (they're only present in the README)
2019-08-28 21:24:09 +02:00
Mads Marquart
7911c2ebae Stop testing Python 2.7 in TravisCI 2019-08-28 21:09:52 +02:00
Mads Marquart
3c00d66ccf Add version warning and begin developing version 2 (for real this time) 2019-08-28 20:53:13 +02:00
Mads Marquart
12e752e681 Bump version: 1.8.0 → 1.8.1 2019-08-28 19:21:39 +02:00
Mads Marquart
1f342d0c71 Move Client._getSendData into the Thread / Group models 2019-08-28 18:07:21 +02:00
Mads Marquart
5e86d4a48a Add method to convert a ThreadType to a subclass of Thread (e.g. Group) 2019-08-28 18:07:21 +02:00
Mads Marquart
0838f84859 Move most of Client._getSendData to State._do_send_request 2019-08-28 18:07:21 +02:00
Mads Marquart
abc938eacd Make State.fb_dtsg private 2019-08-28 18:07:21 +02:00
Mads Marquart
4d13cd2c0b Move body of Client._doSendRequest to State 2019-08-28 18:07:21 +02:00
Mads Marquart
8f8971c706 Move parts of Client._getSendData to Message._to_send_data 2019-08-28 18:07:21 +02:00
Mads Marquart
2703d9513a Move Client._client_id to State 2019-08-28 18:07:21 +02:00
Mads Marquart
3dce83de93 Move Client._upload to State 2019-08-28 18:07:21 +02:00
Mads Marquart
ef8e7d4251 Move user id handling to State 2019-08-28 18:07:21 +02:00
Mads Marquart
a131e1ae73 Move body of Client.graphql_requests to State._graphql_requests 2019-08-28 18:07:21 +02:00
Mads Marquart
84a86bd7bd Move body of Client._payload_post to State 2019-08-28 18:07:21 +02:00
Mads Marquart
adfb5886c9 Move body of Client._post to State 2019-08-28 18:07:21 +02:00
Mads Marquart
8d237ea4ef Move body of Client._get to State 2019-08-28 18:07:21 +02:00
Mads Marquart
513bc6eadf Move Client._do_refresh to State 2019-08-28 18:07:21 +02:00
Mads Marquart
856962af63 Bump version: 1.7.3 → 1.8.0 2019-08-28 10:58:46 +02:00
Daniel Hill
128efe7fba improve animated sticker support 2019-08-01 18:37:13 +12:00
Mads Marquart
7c68a29181 Stop using Client.graphql_request internally 2019-07-25 23:32:17 +02:00
Mads Marquart
2f4e3f2bb1 Remove Client._generatePayload
Make Client._get and Client._post require a query input
2019-07-25 20:20:26 +02:00
Mads Marquart
0389b838bc Merge pull request #455 from carpedm20/add-spell-check
Add spell checking.

Use sphinxcontrib-spelling to fix documentation and docstring spelling errors.
2019-07-25 18:51:53 +02:00
Mads Marquart
441f53e382 Merge pull request #454 from carpedm20/google-style-docstrings
Google docstring style
2019-07-24 21:56:33 +02:00
Mads Marquart
83c45dcf40 Fix spelling / typesetting in various places 2019-07-24 16:18:15 +02:00
Mads Marquart
cc9d81a39e Fix spelling mistakes in documentation 2019-07-24 16:18:15 +02:00
Mads Marquart
edf14cfd84 Add and configure sphinxcontrib-spelling 2019-07-24 16:17:36 +02:00
Mads Marquart
ee79969eda Delete docs/robots.txt
Introduced in a2930b4, but I found out you could deprecate the doc url at /en/master/ using the ReadTheDocs web configuration
2019-07-24 16:15:31 +02:00
Mads Marquart
dbb20b1fdc Convert various directives to Google style sections 2019-07-24 13:45:33 +02:00
Mads Marquart
beee209249 Convert :return: / :rtype: roles to Returns sections 2019-07-24 13:45:33 +02:00
Mads Marquart
d6876ce13b Convert :raises: roles to Raises sections 2019-07-24 13:43:34 +02:00
Mads Marquart
ed05d16a31 Move :type: roles into the Args sections 2019-07-24 13:43:34 +02:00
Mads Marquart
3806f01d2f Convert :param: roles to Args sections 2019-07-24 13:43:30 +02:00
Mads Marquart
5b69ced1e8 Add ability to use Google style docstrings
Use and configure the `napoleon` Sphinx extension
2019-07-24 13:43:02 +02:00
Mads Marquart
6b07f1d8b9 Fix first line of docstrings
- Use the imperative sense
- Use trailing dot
- Omit leading newline
- Grammar / vocabulary fixes
2019-07-24 13:43:01 +02:00
Przemek
700cf14a50 Add fetchThreadImages (#434) 2019-07-24 13:40:00 +02:00
Mads Marquart
1b08243cd2 Fix TODO entries showing file paths of the build system 2019-07-24 00:33:55 +02:00
Mads Marquart
a0b978004c Bump version: 1.7.2 → 1.7.3 2019-07-20 17:09:03 +02:00
Mads Marquart
efc8776e70 Fix login check, close #446
Facebook changed something internally, so that the redirected url is no longer always "/home.php", but instead sometimes just "/"
2019-07-20 17:01:54 +02:00
Szczepan Wiśniowski
915f9a3782 Add heart reaction (#445) 2019-07-20 16:21:44 +02:00
Mads Marquart
e136d77ade Fix 2FA login error, closes #442, replaces #443 2019-07-20 16:00:32 +02:00
Mads Marquart
04aec15833 Fix documentation badge 2019-07-04 00:43:34 +02:00
Mads Marquart
dd5e1024db Bump version: 1.7.1 → 1.7.2 2019-07-04 00:34:11 +02:00
Mads Marquart
31d13f8fae Fix #441, introduced in bc551a6 2019-07-04 00:33:08 +02:00
Mads Marquart
19b4d929e2 Add bump2version (to avoid mistakes like pushing wrong tag names) 2019-07-04 00:25:05 +02:00
Mads Marquart
27e5d1baae Bump version: 1.7.0 -> 1.7.1 2019-07-03 23:59:45 +02:00
Mads Marquart
3a0b9867bc Merge pull request #440 from carpedm20/fix-docs
Fix and clean up documentation
2019-07-03 23:55:16 +02:00
Mads Marquart
a9c681818a Enable strict/explicit code highlighting 2019-07-03 23:42:32 +02:00
Mads Marquart
d279c96dd5 Make docs parsing "nitpicky" 2019-07-03 23:18:02 +02:00
Mads Marquart
d30589d1fa Add rst_prolog to docs/conf.py 2019-07-03 17:46:42 +02:00
Mads Marquart
47c744e5e2 Fix reST any roles/references 2019-07-03 17:35:38 +02:00
Mads Marquart
708869ea93 Include missing models in auto-generated API docs 2019-07-03 17:19:11 +02:00
Mads Marquart
8b47bf3e5d Add instructions for installing with pip > 19.0 2019-07-03 17:16:25 +02:00
Mads Marquart
a2930b4386 Deprecate the doc url at /en/master/ in favor of /en/latest/ 2019-07-03 17:15:21 +02:00
Mads Marquart
2dc93ed18b Add .readthedocs.yml 2019-07-03 15:18:11 +02:00
Mads Marquart
2bd08c8254 Update Sphinx to version 2.0 2019-07-03 14:22:09 +02:00
Mads Marquart
81278ed553 Remove doc configuration entries that are set to the default 2019-07-03 14:21:40 +02:00
Mads Marquart
589cec66e1 Refactor doc files to match format generated by sphinx-quickstart 2019-07-03 14:21:25 +02:00
Mads Marquart
281a20f56a Fix dependency pinning 2019-07-03 12:55:13 +02:00
Mads Marquart
ae8d205dbe Loosely pin dependencies 2019-07-03 11:17:53 +02:00
Mads Marquart
1e6222f46a Optimize BeautifulSoup input field parsing 2019-07-03 11:09:41 +02:00
Mads Marquart
4f2a24848e Use default black "exclude" string 2019-07-03 11:05:16 +02:00
Mads Marquart
e670c80971 Merge pull request #439 from carpedm20/graphql-cleanup
Clean up GraphQL helpers
2019-07-02 19:52:16 +02:00
Mads Marquart
ba7572eddd Merge branch 'master' into graphql-cleanup 2019-07-02 19:17:53 +02:00
Mads Marquart
a5c6fac976 Merge pull request #438 from carpedm20/explicit-error-handling
Add more explicit error handling and improve error handling in general
2019-07-02 18:57:47 +02:00
Mads Marquart
1293814c3a Remove GraphQL object in favor of helper functions 2019-07-02 18:26:35 +02:00
Mads Marquart
1b2aeb01ce Move GraphQL constants into the module 2019-07-02 18:23:29 +02:00
Mads Marquart
cab8abd1a0 Properly namespace GraphQL utility functions 2019-07-02 18:21:00 +02:00
Mads Marquart
edda2386fb Merge pull request #436 from carpedm20/clean-up-requests
Normalize a lot of request parsing
2019-07-02 18:08:19 +02:00
Mads Marquart
b0ad5f6097 Merge pull request #435 from carpedm20/state-refactor
Move state handling into new State model
2019-07-02 18:04:10 +02:00
Mads Marquart
6862bd7be3 Handle errors in payload explicitly 2019-07-02 17:52:10 +02:00
Mads Marquart
bc551a63c2 Improve GraphQL error handling 2019-07-02 17:50:33 +02:00
Mads Marquart
c9f11b924d Add more explicit error handling 2019-07-02 17:32:35 +02:00
Mads Marquart
3236ea5b97 Improve state refresh handler 2019-07-02 17:23:42 +02:00
Mads Marquart
794696d327 Improve payload error handling 2019-07-02 17:23:42 +02:00
Mads Marquart
7345de149a Improve HTTP error handling 2019-07-02 17:23:42 +02:00
Mads Marquart
4fdf0bbc57 Remove JSON conversion from _util.check_request 2019-07-02 17:23:42 +02:00
Mads Marquart
d17f741f97 Refactor _util.check_json 2019-07-02 17:23:42 +02:00
Mads Marquart
4a898b3ff5 Use Client._payload_post helper where relevant 2019-07-02 15:52:02 +02:00
Mads Marquart
7f84ca8d0c Add Client._payload_post helper 2019-07-02 15:50:58 +02:00
Mads Marquart
c3a974a495 Refactor _util.check_request 2019-07-02 15:34:23 +02:00
Mads Marquart
5b57d49a3e Remove Client._postFile 2019-07-02 15:14:02 +02:00
Mads Marquart
7af83c04c0 Remove as_json parameter
The requests that didn't need this parameter were moved to the State model
2019-07-01 22:53:26 +02:00
Mads Marquart
b5ba338f86 Remove fix_request parameter
The requests that don't need this parameter is handled in the State model
2019-07-01 22:49:21 +02:00
Mads Marquart
50bfeb92b2 Add fix_request=True and as_json=True to missing requests
I've tested, these endpoints actually all return JSON data
2019-07-01 22:47:05 +02:00
Mads Marquart
8d41ea5bfd Use POST in Client.fetchImageUrl
Reduces the amount of different request methods we're using.

Not really sure whether this is actually the best option:
- Each request includes `fb_dtsg` and such, so using POST everywhere might be the more secure option?
- But at the same time, the request is more opaque, and harder to debug (urllib3 logs all request urls automatically, so using GET would make that easy)
2019-07-01 18:43:00 +02:00
Mads Marquart
b10b14c8e9 Update url in Client.removeFriend 2019-07-01 18:23:03 +02:00
Mads Marquart
144e81bd46 Add Python 2 support 2019-07-01 13:40:15 +02:00
Mads Marquart
230c849b60 Always create the State object in a valid state 2019-07-01 13:31:42 +02:00
Mads Marquart
466f27a8c5 Move login check code into State 2019-07-01 13:31:42 +02:00
Mads Marquart
dc12e01fc7 Move logout code to State 2019-07-01 13:31:42 +02:00
Mads Marquart
d0e9a7f693 Move login/2fa code to State 2019-07-01 13:31:42 +02:00
Mads Marquart
1ba21e03c6 Handle headers in State 2019-07-01 13:31:42 +02:00
Mads Marquart
bcc8b44bb5 Handle ssl verification in State 2019-07-01 13:31:42 +02:00
Mads Marquart
b01b371c66 Refactor session cookie handling into State 2019-07-01 13:31:42 +02:00
Mads Marquart
94a0f6b3df Move client session into State 2019-07-01 13:31:42 +02:00
Mads Marquart
5df10ecc31 Remove _cleanGet and _cleanPost Client methods 2019-07-01 13:31:42 +02:00
Mads Marquart
56786406ec Refactor most of _postLogin into the State model 2019-07-01 13:31:42 +02:00
Mads Marquart
a4268f36cf Move logout h into the State model 2019-07-01 13:31:42 +02:00
Mads Marquart
8e7afa2edf Move request counter into State model 2019-07-01 13:31:30 +02:00
Mads Marquart
f07122d446 Move request payload into State model 2019-07-01 13:30:29 +02:00
Mads Marquart
78c307780b Clean up a few utility functions 2019-06-29 20:40:11 +02:00
Mads Marquart
ad705d544a Merge pull request #433 from carpedm20/remove-req-url-model
Remove ReqUrl model
2019-06-29 20:24:20 +02:00
Mads Marquart
77f28315c9 Inline urls from ReqUrl 2019-06-29 20:14:49 +02:00
Mads Marquart
e0754031ad Extract pull channel handling from ReqUrl 2019-06-29 20:10:55 +02:00
Mads Marquart
f97d36b41f Add ability to specify urls relative to www.facebook.com 2019-06-29 20:05:16 +02:00
Mads Marquart
bb2afe8e40 Remove redundant timeout parameter 2019-06-23 18:25:30 +02:00
Mads Marquart
faa0383af3 Remove unnecessary default payload attributes
This has been fairly thoroughly tested on all URLs, so it should be safe to do
2019-06-23 18:08:25 +02:00
Mads Marquart
e1e988272b Version up, thanks to @kapi2289
- Removed / privatized a lot of undocumented functionality, which might break some users application if they were relying on those (hence the minor version bump). See #432 for further reasoning
- Add `Client.forwardAttachment` and `Message.forwarded` attribute, see #420
- Fix parsing subattachments with no target, see #412
- Lots of other refactoring, aka. work on the transition towards v2
2019-06-23 18:02:05 +02:00
Mads Marquart
b159f04a6b Merge pull request #432 from carpedm20/undocumented-breaking-changes
Undocumented breaking changes
2019-06-23 17:51:37 +02:00
Mads Marquart
d91a7ea9e3 Remove internal stuff from _graphql in __init__.py 2019-06-23 17:44:03 +02:00
Mads Marquart
8056f3399e Privatize Client.req_url
Undocumented
2019-06-23 17:40:20 +02:00
Mads Marquart
fd9aa7ee90 Remove client.py
Undocumented
2019-06-23 17:38:42 +02:00
Mads Marquart
53c19f473b Remove utils.py
The file, and contained functions, were explicitly documented as something you couldn't rely on for backwards compatibility
2019-06-23 17:38:27 +02:00
Mads Marquart
78b5f05729 Fix search when the result is empty
Return an empty result instead of raising
2019-05-01 23:50:23 +02:00
Mads Marquart
f689376830 Fix check_json
The payload is sometimes a list
2019-05-01 23:49:29 +02:00
Mads Marquart
d244856b41 Fix setting forwarded when creating Message objects 2019-05-01 23:18:12 +02:00
Kacper Ziubryniewicz
3cd0f3a9a7 Add missing mid parameter in message parsing (#426) 2019-05-01 19:32:04 +02:00
Mads Marquart
f480d68b57 Fix subattachment target parsing, fixes #412 2019-04-25 23:26:04 +02:00
Mads Marquart
db2bda1f9b Fix ShareAttachment GraphQL source parsing
Fixes #418
2019-04-25 23:26:03 +02:00
Kacper Ziubryniewicz
f834c01921 Fix sending remote files with URLs containing GET parameters or hashtags (#423) 2019-04-25 22:50:32 +02:00
Mads Marquart
f945fa80b3 Merge pull request #419 from carpedm20/remove-client-variables
Clean up `Client` variables
2019-04-25 22:45:45 +02:00
Kacper Ziubryniewicz
70faa86e34 Add forwarding attachments (#420)
- Add ability to forward attachments and `Message.forwarded` attribute
- Improve error handling for a lot of client methods, including, but not limited to:
    - `fetchAllUsers`
    - `searchForMessageIDs`
    - `search`
    - `fetchThreadInfo` and siblings
    - `fetchUnread`
    - `fetchUnseen`
    - `fetchPollOptions`
    - `fetchPlanInfo`
    - `send` and siblings
    - File uploads
2019-04-25 22:03:03 +02:00
Mads Marquart
61502ed32a Merge pull request #406 from carpedm20/refactor-model-parsing
Refactor model parsing
2019-04-25 21:06:26 +02:00
Mads Marquart
bfca20bb12 Privatize req_counter, payloadDefault and seq client variables
These have complicated semantics, and so are hopefully not depended on externally
2019-04-17 23:24:01 +02:00
Mads Marquart
0fd86d05a1 Privatize sticky and pool client variables
These have complicated semantics, and so are hopefully not depended on externally
2019-04-17 23:22:54 +02:00
Mads Marquart
c688d64062 Make Client.uid read-only
Modifying `uid` was previously documented as giving undefined behaviour, now it'll throw an error
2019-04-17 23:22:23 +02:00
Mads Marquart
2f973f129d Privatize default_thread_X client variables
We have a setter method for them, so there should be no need to access these directly!
2019-04-17 23:20:08 +02:00
Mads Marquart
9b81365b0a Privatize fb_h and client_id variables
These are sparsely used and badly named, so probably not externally depended on externally
2019-04-17 23:20:08 +02:00
Mads Marquart
a079797fca Remove email/password client variables 2019-04-17 23:18:37 +02:00
Mads Marquart
6ab298f6e8 Remove temporary _postLogin variables from the client 2019-04-17 23:18:37 +02:00
Mads Marquart
a159999879 Remove redundant client variables 2019-04-17 23:18:37 +02:00
Mads Marquart
a71835a5b8 Version up, thanks to @kapi2289 and @LukasOndrejka 2019-04-17 22:58:37 +02:00
Mads Marquart
86a6e07804 Merge branch 'master' into refactor-model-parsing 2019-04-17 22:42:20 +02:00
Kacper Ziubryniewicz
73c6be1969 Add message parameter to sending location (#416) 2019-04-17 22:35:04 +02:00
Kacper Ziubryniewicz
7db7868d2b Fix forwarding replied messages (#417) 2019-04-17 22:34:19 +02:00
Mads Marquart
18ec1f5680 Merge branch 'master' into refactor-model-parsing 2019-04-17 22:06:07 +02:00
Mads Marquart
8e65074b11 Hotfixes
Fix mistakes introduced in #415
2019-04-17 21:56:50 +02:00
Mads Marquart
d720438aef Merge pull request #415 from carpedm20/cleanup
Cleanup
2019-04-17 21:11:18 +02:00
Mads Marquart
ec0e3a91d1 Merge branch 'master' into cleanup 2019-04-17 20:50:08 +02:00
Mads Marquart
48e7203ca6 Rename internal variable 2019-04-17 20:47:49 +02:00
Mads Marquart
4f76b79629 Merge pull request #409 from kapi2289/message-reply
Add replying to messages
2019-04-17 20:44:30 +02:00
Mads Marquart
1eeae78a9f Small refactoring
The `muteX` methods return values are now checked using `check_request`, `seq` is now parsed in `_parseMessage` and a few other things.
2019-03-31 00:16:36 +01:00
Mads Marquart
bc27f756ed Split long strings, use format when creating strings 2019-03-31 00:11:20 +01:00
Mads Marquart
6302d5fb8b Split overly nested calls 2019-03-31 00:07:29 +01:00
Mads Marquart
24e238c425 Remove superfluous whitespace 2019-03-31 00:02:49 +01:00
Mads Marquart
070f57fcc4 Refactor _graphql away 2019-03-30 21:20:20 +01:00
Kacper Ziubryniewicz
a4ce45e9b0 Add detecting replied messages while listening 2019-03-29 21:09:19 +01:00
Kacper Ziubryniewicz
a3efa7702a Add possibility to reply to messages
and to (partly) fetch the replied messages
2019-03-23 21:26:43 +01:00
LukasOndrejka
d7a5d00439 Add new colors (#393)
Color names are from https://www.htmlcsscolor.com/
2019-03-12 18:15:11 +01:00
Mads Marquart
6636d49cc0 Remove graphql.py 2019-03-10 20:30:22 +01:00
Mads Marquart
8e6ee4636e Move gender dict into _user.py 2019-03-10 20:25:02 +01:00
Mads Marquart
71f19dd3c7 Move fetchAllUsers parsing into User._from_all_fetch 2019-03-10 20:22:56 +01:00
Mads Marquart
e166b472c5 Move message pull parsing into Message._from_pull 2019-03-10 20:10:19 +01:00
Mads Marquart
28c867a115 Simplify _graphql.py imports 2019-03-10 19:54:36 +01:00
Mads Marquart
f20a04b2a0 Move graphql_to_message -> Message._from_graphql 2019-03-10 19:50:06 +01:00
Mads Marquart
1f961b2ca7 Move thread parser dispatching into _client.py
I know, it's not pretty, but it doesn't belong in _graphql.py either
2019-03-10 19:39:22 +01:00
Mads Marquart
e579e0c767 Move graphql_to_quick_reply into _quick_reply.py 2019-03-10 19:35:07 +01:00
Mads Marquart
6693ec9c36 Move graphql_to_extensible_attachment into _message.py 2019-03-10 19:33:58 +01:00
Mads Marquart
53856a3622 Move attachment and subattachment dispatching to _file.py 2019-03-10 19:26:01 +01:00
Mads Marquart
0b99238676 Move subattachment parsing to the respective models 2019-03-10 19:19:50 +01:00
Mads Marquart
cb2c68e25a Move graphql_to_page -> Page._from_graphql 2019-03-10 17:36:41 +01:00
Mads Marquart
fd5553a9f5 Move graphql_to_group -> Group._from_graphql 2019-03-10 17:33:46 +01:00
Mads Marquart
60ebbd87d8 Move graphql_to_thread user parsing to User._from_thread_fetch 2019-03-10 17:26:47 +01:00
Mads Marquart
3a5185fcc8 Move graphql_to_user -> User._from_graphql 2019-03-10 17:21:06 +01:00
Mads Marquart
ce469d5e5a Move get_customization_info -> Thread._parse_customization_info 2019-03-10 17:02:03 +01:00
Mads Marquart
4f0f126e48 Make Github Releases deploy in the published state 2019-03-10 16:45:16 +01:00
Mads Marquart
94c30a2440 Merge pull request #405 from carpedm20/private-api
Privatize `client`, `utils` and `graphql` submodules
2019-03-10 16:42:48 +01:00
Mads Marquart
1460b2f421 Version up, thanks to @oneblue and @darylkell 2019-03-10 16:33:44 +01:00
Mads Marquart
968223690e Move plan parsing to the Plan model
- Add `GuestStatus` enum
- Add `Plan.guests` property
- Made `Plan.going`, `Plan.declined` and `Plan.invited` property accessors to `Plan.guests`
2019-03-10 16:21:22 +01:00
Mads Marquart
789d9d8ca1 Split graphql_to_attachment into smaller methods 2019-03-07 21:22:56 +01:00
Mads Marquart
2ce99a2c44 Split graphql_to_extensible_attachment into smaller methods 2019-03-07 20:50:14 +01:00
Mads Marquart
ee207e994f Move graphql_to_live_location -> LiveLocationAttachment._from_pull 2019-03-07 20:17:29 +01:00
Mads Marquart
c374aca890 Fix _util exception import 2019-03-07 19:59:25 +01:00
Mads Marquart
c28ca58537 Add missing attributes to Poll and PollOption __init__ 2019-03-07 19:58:24 +01:00
Mads Marquart
0578ea2c3c Move graphql_to_poll -> Poll._from_graphql 2019-03-07 19:52:29 +01:00
Mads Marquart
e51ce99c1a Move graphql_to_poll_option -> PollOption._from_graphql 2019-03-07 19:47:36 +01:00
Mads Marquart
3440039610 Move graphql_to_sticker -> Sticker._from_graphql 2019-03-07 19:07:00 +01:00
Mads Marquart
279f637c75 Move graphql_color_to_enum -> ThreadColor._from_graphql 2019-03-07 18:54:38 +01:00
Mads Marquart
d940b64517 Move enum_extend_if_invalid -> Enum._extend_if_invalid 2019-03-07 18:42:58 +01:00
Mads Marquart
403870e39e Move emojisize pull parsing into the model 2019-03-07 18:13:05 +01:00
Mads Marquart
0383d613e6 Move ActiveStatus pull parsing into the model 2019-03-07 18:12:37 +01:00
Mads Marquart
40e9825ee0 Add deprecated public graphql module 2019-02-24 23:21:17 +01:00
Mads Marquart
ab9ca94181 Rename graphql.py -> _graphql.py 2019-02-24 23:17:36 +01:00
Mads Marquart
0f99a23af7 Add deprecated public utils module 2019-02-24 23:16:40 +01:00
Mads Marquart
bc5163adaf Rename utils.py -> _util.py 2019-02-24 23:15:12 +01:00
Mads Marquart
0561718917 Import utils.py and graphql.py directly into the global module 2019-02-24 23:08:23 +01:00
Mads Marquart
c1861627fb Make deprecated public client module 2019-02-24 23:06:29 +01:00
Mads Marquart
e5eccab871 Rename client.py -> _client.py 2019-02-24 23:01:26 +01:00
Mads Marquart
27f76ba659 Merge pull request #400 from carpedm20/pull-delta-refactor
Move pull delta parsing into separate method
2019-02-24 20:52:26 +01:00
Mads Marquart
589117b9e7 Move pull delta parsing into _parseDelta (commit 2) 2019-02-24 20:45:44 +01:00
Mads Marquart
80300cd160 Move pull delta parsing into _parseDelta (commit 1) 2019-02-24 20:45:01 +01:00
Mads Marquart
76171408cc Merge pull request #399 from carpedm20/attrs
Use attrs to declare our models
2019-02-24 20:24:21 +01:00
Mads Marquart
c1800a174f Update minimum attrs version 2019-02-24 20:18:11 +01:00
Mads Marquart
8ae8435940 Use attrs, to remove verbose __init__ and __repr__ methods
Backwards compatibility is strictly preserved in `__init__`, including parameter names, defaults and position. Whenever that's difficult using `attrs`, the custom `__init__` is kept instead (for the time being).

`__repr__` methods have changed to the format `attrs` use, but people don't rely on this for anything other than debug output, so it shouldn't be a problem.
2019-02-24 20:18:07 +01:00
Mads Marquart
f916cb3b53 Add attrs as dependency 2019-02-24 20:18:04 +01:00
Mads Marquart
929c2137bf Move model docstrings into the class level, out of init 2019-02-24 20:18:00 +01:00
Mads Marquart
98056e91c5 Split models.py into several files (#398)
* Move exception models into separate file
* Move thread model into separate file
* Move user model into separate file
* Move group and room models into separate file
* Move page model into separate file
* Move message model into separate file
* Move basic attachment models into separate file
* Move sticker model into separate file
* Move location models into separate file
* Move file attachment models into separate file
* Move mention model to reside with message model
* Move quick reply models into separate file
* Move poll models into separate file
* Move plan model into separate file
* Move active status model to reside with user model
* Move core enum model into separate file
* Move thread-related enums to reside with thread model
* Move typingstatus model to reside with user model
* Move emojisize and reaction enums to reside wtih message model
2019-02-24 20:06:59 +01:00
Mads Marquart
944a7248c3 Disable travis email notifications 2019-02-24 02:17:03 +01:00
darylkell
caa2ecd0b7 Fix LocationAttachment (#395)
Set `LocationAttachment.address` instead of `latitude` and `longitude`, when no GPS coords are supplied. Fixes #392
2019-02-19 12:19:20 +01:00
Blue
dfc2d0652f Make fetchUnread and fetchUnseen include group chats (#394)
* Correct fetchUnread and fetchUnseen to include 1:1 chats and group chats
2019-02-18 22:37:16 +01:00
Mads Marquart
8d25540445 Version up, thanks to @kapi2289 2019-02-03 22:07:44 +01:00
Mads Marquart
6ea174bfd4 Merge pull request #389 from kapi2289/fix-388
Fix #388 issue
2019-02-03 22:06:26 +01:00
Kacper Ziubryniewicz
56e43aec0e Apply suggestions and fixes from review 2019-02-03 19:03:43 +01:00
Kacper Ziubryniewicz
491d120c25 Fix #388 issue 2019-02-03 14:45:10 +01:00
Mads Marquart
82d071d52c Version up 2019-01-31 21:27:04 +01:00
Mads Marquart
8190654a91 Add section about black in CONTRIBUTING.rst 2019-01-31 21:09:15 +01:00
Mads Marquart
5e21702d16 Add black code style badge 2019-01-31 21:00:17 +01:00
Mads Marquart
3df4172237 Add travis format checking step 2019-01-31 20:59:48 +01:00
Mads Marquart
e0710a2ec1 Format strings using black 2019-01-31 20:55:22 +01:00
Mads Marquart
d20fc3b9ce Format using black (without string normalization) 2019-01-31 20:54:32 +01:00
Mads Marquart
f25faec108 Version up 2019-01-31 20:26:17 +01:00
Mads Marquart
2750658c3c Fix #385 2019-01-31 20:26:04 +01:00
Mads Marquart
e6bc5bbab3 Version up, thanks to @kapi2289 and @2FWAH 2019-01-31 20:20:17 +01:00
Mads Marquart
de5f3a9d9e Merge branch 'pr/300' 2019-01-31 20:13:27 +01:00
Mads Marquart
7f0da012c2 Few nitpicky fixes 2019-01-31 20:12:59 +01:00
Mads Marquart
76ecbf5eb0 Merge branch 'pr/325' 2019-01-31 19:57:22 +01:00
Mads Marquart
06881a4c70 Add formatMentions docstring 2019-01-31 19:56:35 +01:00
Mads Marquart
c14fdd82db Merge branch 'pr/338' 2019-01-31 19:29:54 +01:00
Mads Marquart
b1a02ad930 Merge pull request #342 from kapi2289/quick_replies
[Feature] Quick replies
2019-01-31 19:26:03 +01:00
Mads Marquart
2b580c60e9 Readd deprecated markAlive parameter 2019-01-31 19:23:46 +01:00
Mads Marquart
27ffba3b14 Fix a few isinstance checks 2019-01-31 19:21:52 +01:00
Mads Marquart
fb7bf437ba Merge pull request #384 from carpedm20/github-releases-ci
Automatic GitHub Releases
2019-01-25 20:01:37 +01:00
Mads Marquart
d8baf0b9e7 Put automatic GitHub releases in the draft state
This is done so that I can edit the description as needed, before publishing
2019-01-25 19:35:20 +01:00
Mads Marquart
a6945fe880 Merge branch 'disable-online-tests' 2019-01-25 19:18:03 +01:00
Mads Marquart
6ff77dd8c7 Merge pull request #382 from carpedm20/flit
Use `flit` as our build system
2019-01-25 19:14:04 +01:00
Mads Marquart
1d925a608b Update pypy version to 3.5 2019-01-25 18:53:14 +01:00
Mads Marquart
646669ca75 Add Github Releases deployment 2019-01-25 18:49:56 +01:00
Mads Marquart
0ec2baaa83 Add Python 3.7 testing 2019-01-25 18:49:35 +01:00
Mads Marquart
5abaaefd1c Disable Travis online tests 2019-01-25 18:49:08 +01:00
Mads Marquart
687afea0f2 Pin minimum pytest version to fix tests 2019-01-25 17:45:15 +01:00
Mads Marquart
7398d4fa2b Use --python option to properly install the package under Python 2.7 2019-01-25 17:14:14 +01:00
Mads Marquart
d73c8c3627 Fix travis setup for running flit under Python 2.7 2019-01-25 17:05:35 +01:00
Mads Marquart
f921b91c5b Make travis use flit 2019-01-25 16:43:22 +01:00
Mads Marquart
8ed3c1b159 Use flit instead of setuptools
Mostly just a simple move from `setup.cfg` -> `pyproject.toml`. Had to reformat the description in `__init__` a little though.
2019-01-25 16:36:09 +01:00
Mads Marquart
4f947cdbb5 Version up, thanks to @kapi2289 and @kaushalvivek 2019-01-25 16:01:47 +01:00
Mads Marquart
ec6c29052a Merge pull request #371 from carpedm20/fix-enums
Fix `ThreadColor` and `MessageReaction` enums
2019-01-24 22:42:41 +01:00
Mads Marquart
6b117502f3 Merge branch 'master' into fix-enums 2019-01-24 22:40:44 +01:00
Kacper Ziubryniewicz
a367aa0b31 Replying on location quick replies 2019-01-05 20:40:45 +01:00
Kacper Ziubryniewicz
7f6843df55 Better quick reply types 2019-01-05 20:06:28 +01:00
Kacper Ziubryniewicz
4b485d54b6 Merge remote-tracking branch 'origin/master' into quick_replies 2019-01-05 19:29:32 +01:00
Kacper Ziubryniewicz
e80a040db4 Deprecate markAlive parameter in doOneListen and _pullMessage 2019-01-05 18:48:40 +01:00
Kacper Ziubryniewicz
c357fd085b Better listening for buddylist overlay and chatbox presence 2019-01-05 18:36:48 +01:00
Kacper Ziubryniewicz
d0c5f29b0a Fixed getting active status 2019-01-05 18:24:23 +01:00
Mads Marquart
3e7b20c379 Merge pull request #377 from kapi2289/fix-fbchatexception
Fixed typos in FBchatException
2019-01-05 18:20:38 +01:00
Kacper Ziubryniewicz
f4a997c0ef Fixed typos in FBchatException 2019-01-05 17:55:54 +01:00
Kacper Ziubryniewicz
102e74bb63 Merge remote-tracking branch 'origin/master' into active_status 2019-01-05 17:46:27 +01:00
Mads Marquart
84fa15e44c Merge pull request #333 from kapi2289/extensible_attachments
[Feature] Extensible attachments
2019-01-04 21:06:11 +01:00
Kacper Ziubryniewicz
7b8ecf8fe3 Changed deleted to unsent 2019-01-04 20:02:00 +01:00
Kacper Ziubryniewicz
79ebf920ea More on responding to quick replies 2019-01-03 23:28:23 +01:00
Kacper Ziubryniewicz
0d05d42f70 getPhoneNumbers and getEmails methods 2019-01-03 22:54:47 +01:00
Kacper Ziubryniewicz
95989b6da7 Merge branch 'master' into extensible_attachments 2018-12-23 14:58:03 +01:00
Kacper Ziubryniewicz
22e57f99a1 deleted attribute of Message
and batter handling of deleted (unsended) messages
2018-12-23 14:56:27 +01:00
Kacper Ziubryniewicz
b9d29c0417 Removed addReaction, removeReaction, _react
(and undeprecated `reactToMessage`)
2018-12-23 14:45:17 +01:00
Kacper Ziubryniewicz
edc33db9e8 Few fixes in quick replies 2018-12-23 14:36:26 +01:00
Mads Marquart
45d8b45d96 Fix enum_extend_if_invalid warning 2018-12-12 23:22:08 +01:00
Mads Marquart
b6a6d7dc68 Move enum_extend_if_invalid to utils.py 2018-12-12 23:06:16 +01:00
Mads Marquart
c57b84cd0b Refactor enum extending 2018-12-12 23:04:26 +01:00
Mads Marquart
78e7841b5e Extend MessageReaction when encountering unknown values 2018-12-12 22:53:23 +01:00
Mads Marquart
e41d981449 Extend ThreadColor when encountering unknown values 2018-12-12 22:44:19 +01:00
Mads Marquart
381227af66 Make use aenum instead of the default enum 2018-12-12 22:39:31 +01:00
Mads Marquart
2f8d0728ba Merge pull request #366 from kaushalvivek/master
Fix for issue #365
2018-12-10 21:16:57 +01:00
kaushalvivek
13bfc5f2f9 Fix for search limit 2018-12-10 14:46:04 +05:30
Mads Marquart
f8d3b571ba Version up, thanks to @ekohilas and @kapi2289 2018-12-09 21:21:00 +01:00
Mads Marquart
64b1e52d4c Merge pull request #357 from carpedm20/fixed-listening
Fixed listening
2018-12-09 19:23:33 +01:00
Mads Marquart
b650f7ee9a Merge pull request #367 from carpedm20/fix-pytest-deprecation
Fix pytest "Applying marks directly to parameters" deprecation
2018-12-09 19:23:20 +01:00
Kacper Ziubryniewicz
d4446280c7 Detecting when someone unsends a message 2018-12-09 15:27:01 +01:00
Mads Marquart
3443a233f4 Fix pytest "Applying marks directly to parameters" deprecation 2018-12-09 15:02:48 +01:00
Kacper Ziubryniewicz
861f17bc4d Added DeletedMessage attachment 2018-12-09 14:55:10 +01:00
Kacper Ziubryniewicz
41bbe18e3d Unsending messages 2018-12-09 14:36:23 +01:00
Kacper Ziubryniewicz
5f9c357a15 Fixed graphql and added method for replying on quick replies 2018-12-09 01:07:33 +01:00
Kacper Ziubryniewicz
c089298f46 Sending new quick replies 2018-12-09 00:57:58 +01:00
Kacper Ziubryniewicz
be968e0caa New models for quick replies 2018-12-09 00:32:44 +01:00
Vivek Kaushal
d32b7b612a Fix for issue #365 2018-12-07 21:26:48 +05:30
Mads Marquart
160386be62 Added support for request_batch parsing in _parseMessage 2018-11-09 20:08:26 +01:00
Mads Marquart
64bdde8f33 Sticky and pool parameters can be set after the inital _fetchSticky 2018-11-07 20:06:10 +01:00
Kacper Ziubryniewicz
8739318101 Sending voice clips 2018-10-30 22:24:47 +01:00
Kacper Ziubryniewicz
1ac569badd Sending pinned or current location 2018-10-30 22:21:05 +01:00
Kacper Ziubryniewicz
e38f891693 Active status fixes 2018-10-30 21:48:55 +01:00
Mads Marquart
89a277c354 Merge pull request #354 from ekohilas/master
separate spellchecked docs
2018-10-28 12:46:48 +01:00
Mads Marquart
8238387c7d Merge pull request #353 from ekohilas/docstrings
completed todo for graphql_requests
2018-10-28 12:45:37 +01:00
ekohilas
6c829581af completed todo for graphql_requests 2018-10-27 02:02:15 +11:00
ekohilas
d180650c1b spellchecked docs 2018-10-25 18:18:19 +11:00
Mads Marquart
772bf5518f Merge pull request #346 from kapi2289/remove_unnecessary
Remove unnecessary code
2018-10-07 16:50:31 +02:00
Kacper Ziubryniewicz
153dc0bdad Remove unnecessary code 2018-10-07 16:27:19 +02:00
Kacper Ziubryniewicz
b7ea8e6001 New sendLocation method 2018-09-29 13:48:08 +02:00
Kacper Ziubryniewicz
b0bf5ba8e0 Update graphql.py 2018-09-29 13:42:11 +02:00
Kacper Ziubryniewicz
8169a5f776 Changed LocationAttachment 2018-09-29 13:40:38 +02:00
Mads Marquart
b4b8914448 Version up, thanks to @kapi2289 2018-09-27 21:53:12 +02:00
Mads Marquart
2ea2c89b4a Fixed markAsRead and markAsUnread, fixes #336 2018-09-27 21:44:04 +02:00
Mads Marquart
479ca59a6a Merge pull request #341 from kapi2289/read_by
[Feature] New `read_by` attribute of `Message`
2018-09-27 20:56:13 +02:00
Mads Marquart
343f987a78 Merge pull request #340 from kapi2289/fix_fetch_thread_list
[Fix] `fetchThreadList` fix
2018-09-27 20:27:03 +02:00
Kacper Ziubryniewicz
492465a525 Update graphql.py 2018-09-25 18:00:44 +02:00
Kacper Ziubryniewicz
f185e44f93 Update models.py 2018-09-25 17:59:16 +02:00
Kacper Ziubryniewicz
5f2c318baf Sending quick replies 2018-09-24 21:04:21 +02:00
Kacper Ziubryniewicz
531a5b77d0 GraphQL method for quick replies 2018-09-24 20:57:19 +02:00
Kacper Ziubryniewicz
f9245cdfed New model and Message attribute
New `QuickReply` model and `quick_replies` attribute of `Message` model.
2018-09-24 20:54:25 +02:00
Kacper Ziubryniewicz
bad9c7a4b9 read_by handling 2018-09-24 20:33:43 +02:00
Kacper Ziubryniewicz
576e0949e0 New read_by attribute in Message 2018-09-24 20:32:04 +02:00
Kacper Ziubryniewicz
d807648d2b fetchThreadList fix 2018-09-24 16:50:15 +02:00
Kacper Ziubryniewicz
47ea88e025 Read commit description
- Fixed `onImageChange` documentation and added missing `msg` parameter
- Moved `on` methods to the right place
- Added changing client active status while listening
- Added fetching friends' active status
2018-09-22 21:52:40 +02:00
Kacper Ziubryniewicz
345a473ee0 ActiveStatus model 2018-09-22 21:34:44 +02:00
Kacper Ziubryniewicz
c6dc432d06 Move on methods to the right place 2018-09-22 20:39:41 +02:00
2FWAH
af3bd55535 Add basic test for fetchThreads 2018-09-21 19:43:39 +02:00
2FWAH
5fa1d86191 Add before, after and limit parameters to fetchThreads 2018-09-21 19:12:46 +02:00
2FWAH
d4859b675a Fix ident for _forcedFetch 2018-09-21 17:36:16 +02:00
2FWAH
9aa427031e Merge from upstream and solve conflict in fbchat/client.py 2018-09-21 17:29:58 +02:00
Kacper Ziubryniewicz
9e8fe7bc1e Fix Python 2.7 compability 2018-09-15 11:34:16 +02:00
Kacper Ziubryniewicz
90813c959d Added get_url_parameters util method 2018-09-15 11:21:35 +02:00
Kacper Ziubryniewicz
940a65954c Read commit description
Added:
- Detecting extensible attachments
- Fetching live user location
- New methods for message reacting
- New `on` methods: `onReactionAdded`, `onReactionRemoved`, `onBlock`, `onUnblock`, `onLiveLocation`
- Fixed `size` of attachments
2018-09-12 17:52:38 +02:00
Kacper Ziubryniewicz
9b4e753a79 Added graphql methods for extensible attachments 2018-09-12 17:48:35 +02:00
Kacper Ziubryniewicz
e0be9029e4 Added extensible attachments models 2018-09-12 17:48:00 +02:00
Kacper Ziubryniewicz
0ae213c240 Merge pull request #1 from carpedm20/master
Merge `master`
2018-09-12 17:41:53 +02:00
Mads Marquart
08117e7a54 Fixed examples, see #332
The examples were using generator expressions instead of list comprehensions
2018-09-09 14:24:20 +02:00
Mads Marquart
51c3226070 Merge pull request #326 from kapi2289/merge_rooms
Merge `Room` with `Group`
2018-09-09 14:09:36 +02:00
Mads Marquart
5396d19d7d Merge pull request #327 from kapi2289/fix_active
`markAlive` fix
2018-09-09 14:07:48 +02:00
Kacper Ziubryniewicz
11501e6899 Fix Room model initialization 2018-09-03 15:05:11 +02:00
Kacper Ziubryniewicz
4eb49b9119 Backwards compability for Rooms 2018-08-31 13:25:37 +02:00
Kacper Ziubryniewicz
4c2da22750 markAlive fix 2018-08-30 20:28:32 +02:00
Kacper Ziubryniewicz
753b9cbae2 Merge Room with Group methods 2018-08-30 19:57:47 +02:00
Kacper Ziubryniewicz
2c73cabe22 Merge Room with Group graphql methods 2018-08-30 19:57:12 +02:00
Kacper Ziubryniewicz
d6ca091b7b Merge Room with Group model 2018-08-30 19:56:18 +02:00
Kacper Ziubryniewicz
aa3faca246 Added formatMentions method 2018-08-30 15:57:16 +02:00
Mads Marquart
f0e849e9c0 Version up, thanks to @kapi2289, @gave92, @ThatAlexanderA and @1ttric 2018-08-30 00:08:27 +02:00
Mads Marquart
ddcbd6a790 Merge pull request #318 from kapi2289/master
Bunch of new methods, bunch of fixes, bunch of tests
2018-08-29 23:55:17 +02:00
Mads Marquart
28e3b6285e Made mute methods raise if they errored 2018-08-29 23:51:33 +02:00
Mads Marquart
348db90f7b Fixes for Python 2.7 compatibility 2018-08-29 23:50:35 +02:00
Mads Marquart
0d780b9b80 Added tests for plans 2018-08-29 21:31:28 +02:00
Mads Marquart
8ab718becd Added poll tests 2018-08-29 16:49:33 +02:00
Kacper Ziubryniewicz
1943c357fa Message searching rebuild
Changed message searching methods to return generators and added `search`
2018-08-29 15:14:26 +02:00
Mads Marquart
3be0d8389b Changed changeThreadImageX to changeGroupImageX 2018-08-29 14:37:29 +02:00
Kacper Ziubryniewicz
d7d1c83276 MessageReactionFix is not needed anymore 2018-08-29 14:33:48 +02:00
Mads Marquart
8591e2ffd5 Fixed createGroup implementation 2018-08-29 14:08:11 +02:00
Mads Marquart
c2225bf2fd Added more tests 2018-08-29 14:07:44 +02:00
Mads Marquart
0617d7b49f Fixed _usersApproval, fixed changeThreadImage methods, more tests 2018-08-29 12:17:16 +02:00
Mads Marquart
42b288ee98 Fixed onAdminRemoved and onAdminAdded, and added tests for that 2018-08-29 11:15:59 +02:00
Mads Marquart
ead7203e40 Added tests for fetchMessageInfo 2018-08-29 11:03:46 +02:00
Mads Marquart
bd2b947255 More test improvements 2018-08-29 10:14:18 +02:00
Mads Marquart
f367bd2d0d Improved test setup 2018-08-29 10:12:10 +02:00
Kacper Ziubryniewicz
a8ce44b109 Added searching for messages in all threads 2018-08-27 19:37:49 +02:00
Kacper Ziubryniewicz
3b43d3f0bd Few fixes 2018-08-27 14:08:19 +02:00
Kacper Ziubryniewicz
06da486140 Backwards compability for plans/event reminders 2018-08-24 21:56:31 +02:00
Kacper Ziubryniewicz
a24a7d5636 Small documentation fix 2018-08-23 21:10:47 +02:00
Mads Marquart
bc197fd665 Changed sendXFiles to only needing file url / path 2018-08-23 20:38:55 +02:00
Kacper Ziubryniewicz
e35cc71cf4 Fix plan fetching from threads 2018-08-23 12:17:22 +02:00
Kacper Ziubryniewicz
7aa774b4ef Update utils.py 2018-08-20 23:12:36 +02:00
Kacper Ziubryniewicz
9bb2de79fa Update client.py 2018-08-20 23:12:10 +02:00
Kacper Ziubryniewicz
21246144ab Update client.py 2018-08-20 17:09:18 +02:00
Kacper Ziubryniewicz
0e0845914b Update graphql.py 2018-08-20 16:57:37 +02:00
Kacper Ziubryniewicz
778e827277 Update models.py 2018-08-20 16:57:10 +02:00
Kacper Ziubryniewicz
f36d4fa38d client - Event to Plan 2018-08-19 15:28:22 +02:00
Kacper Ziubryniewicz
5b89c2d504 utils - Event to Plan 2018-08-19 15:25:02 +02:00
Kacper Ziubryniewicz
49b213bb2d graphql - Event to Plan 2018-08-19 15:24:28 +02:00
Kacper Ziubryniewicz
aed75c7d1b Changed Event model to Plan 2018-08-19 15:23:44 +02:00
Mads Marquart
ac51e4e4d5 Removed trailing whitespace 2018-08-13 21:28:17 +02:00
Kacper Ziubryniewicz
d8d84ae629 Fix event_reminders for pages 2018-08-11 14:29:31 +02:00
Kacper Ziubryniewicz
3f75f8ed31 Added markAsSpam 2018-08-10 12:03:14 +02:00
Kacper Ziubryniewicz
8aef4dc2ec Added mark as spam request 2018-08-10 12:02:47 +02:00
Kacper Ziubryniewicz
b1e7ec706b Fix event_reminders 2018-08-10 10:03:51 +02:00
Kacper Ziubryniewicz
b5cd780360 Added message searching 2018-08-10 09:09:17 +02:00
Kacper Ziubryniewicz
a8da94ee6d Added request for message searching 2018-08-10 09:08:34 +02:00
Kacper Ziubryniewicz
f564c732d4 Added event reminder methods 2018-08-09 20:05:59 +02:00
Kacper Ziubryniewicz
8beb1e5753 Update graphql.py 2018-08-09 20:04:20 +02:00
Kacper Ziubryniewicz
d98d802a33 New Event model 2018-08-09 20:02:45 +02:00
Kacper Ziubryniewicz
d750f29fad New event reminder requests 2018-08-09 20:01:52 +02:00
Kacper Ziubryniewicz
f425d32846 Added poll methods 2018-08-05 22:15:42 +02:00
Kacper Ziubryniewicz
043d6b492d Fix in new graphql methods 2018-08-05 22:09:03 +02:00
Kacper Ziubryniewicz
0bcccfa65e Added graphql_to_poll and graphql_to_poll_option 2018-08-05 22:01:43 +02:00
Kacper Ziubryniewicz
0716b1b8d8 Added requests for poll events 2018-08-05 21:58:48 +02:00
Kacper Ziubryniewicz
47168e682d Added Poll and PollOption models 2018-08-05 21:56:32 +02:00
Kacper Ziubryniewicz
718d864dc8 Added file, video and audio sending 2018-08-04 00:43:36 +02:00
Kacper Ziubryniewicz
22a691ec0f Fix waveToThread 2018-08-03 21:55:06 +02:00
Kacper Ziubryniewicz
dfcc826b7e Added waveToThread and markAsUnread 2018-08-02 23:31:35 +02:00
Kacper Ziubryniewicz
d1ee664ef5 Added deleteMesseges request url 2018-08-01 22:55:42 +02:00
Kacper Ziubryniewicz
abcc6518bb Added deleteMessages method 2018-08-01 22:53:48 +02:00
Kacper Ziubryniewicz
2ef9ec3358 Added call events
Added onCallStarted, onCallEnded and onUserJoinedCall but this methods are for group calls only. I can't find how to fetch private call start, I found only how to fetch private call end.
2018-07-31 23:16:45 +02:00
Kacper Ziubryniewicz
f84cf3bf2d Added fetchMessageInfo by mid and thread_id
Added fetchMessageInfo and fixed onImageChange when removing thread image
2018-07-31 20:12:24 +02:00
Kacper Ziubryniewicz
bdcc2d2fa4 Added acceptUsersToGroup and denyUsersFromGroup 2018-07-31 13:23:35 +02:00
Kacper Ziubryniewicz
7e8e7f15a4 Update client.py 2018-07-31 12:09:03 +02:00
Kacper Ziubryniewicz
1ca3ad6237 Forgot about thread_type in new methods. Added it! 2018-07-31 11:56:43 +02:00
Kacper Ziubryniewicz
f3c878d949 Update client.py 2018-07-31 11:48:25 +02:00
Kacper Ziubryniewicz
ee0c30ebb1 Update utils.py 2018-07-31 11:33:20 +02:00
Kacper Ziubryniewicz
c2f0c908d9 Added thread muting 2018-07-31 11:30:41 +02:00
kapi2289
3edaaa0400 Added deleteThreads
Added deleteThreads and made few fixes
2018-07-31 10:40:10 +02:00
kapi2289
21a443baf2 Update client.py 2018-07-31 00:03:19 +02:00
kapi2289
f6f47b5500 Merge branch 'master' into master 2018-07-29 15:20:12 +02:00
Mads Marquart
920c724656 Merge pull request #317 from gave92/master
Fix 2FA for non-English users
2018-07-28 19:34:39 +02:00
Mads Marquart
e50b814e07 Merge pull request #316 from ThatAlexanderA/patch-1
Added `createGroup`
2018-07-28 19:32:55 +02:00
kapi2289
2294082168 Documentation fix #2 2018-07-20 15:24:18 +02:00
kapi2289
2661a28936 Multiple admins adding/removing
Changed
addGroupAdmin, removeGroupAdmin
to
addGroupAdmins, removeGroupAdmins
2018-07-20 12:42:18 +02:00
kapi2289
31a6834b1f Documentation fix 2018-07-20 12:01:05 +02:00
kapi2289
f66d98bcfe Wrong change #2 2018-07-20 11:56:39 +02:00
kapi2289
ed7466621f Wrong change 2018-07-20 11:51:03 +02:00
kapi2289
ead450aeb8 Update utils.py 2018-07-19 17:38:04 +02:00
kapi2289
d934cefa8b New methods and few fixes
Added: addGroupAdmin, removeGroupAdmin, changeGroupApprovalMode, blockUser, unblockUser, moveThread, onImageChange, onAdminsAdded, onAdminsRemoved, onApprovalModeChange
I did this all day, because I love this library and I want to be part of it :D
2018-07-19 17:36:54 +02:00
kapi2289
41807837b8 Small typo fix 2018-07-16 21:46:58 +02:00
Marco Gavelli
4419c816f5 Fix 2FA for non english FB 2018-07-15 12:37:20 +02:00
ThatAlexanderA
4993da727a Added create group url 2018-07-14 12:42:18 +02:00
ThatAlexanderA
86a163e337 Added create group def 2018-07-14 12:40:42 +02:00
Mads Marquart
c2fb602bee Disabled travis pytest caching, now the tests should be pretty stable 2018-07-12 17:42:34 +02:00
Mads Marquart
f565d6f31a Merge pull request #311 from kapi2289/master
Fixed changeThreadTitle and added changeThreadImage
2018-07-12 16:47:23 +02:00
kapi2289
5af01bb8ff Added documentation 2018-07-08 14:37:44 +02:00
kapi2289
714e783e0d Update client.py 2018-07-07 22:39:02 +02:00
Mads Marquart
fb1b0afddb Merge pull request #306 from carpedm20/improve_community_profile
Improve community profile
2018-07-07 15:36:43 +02:00
kapi2289
e6fdc56d25 Update utils.py 2018-07-03 23:14:48 +02:00
kapi2289
5b965e63f8 Update client.py 2018-07-03 23:13:47 +02:00
Mads Marquart
af86550e71 Merge pull request #307 from 1ttric/master
Fix: Name edge case results in IndexError
2018-07-02 14:07:11 +02:00
Will Vesey
e57ae069a7 Fix name edge case 2018-06-27 13:54:45 -04:00
Mads Marquart
39adc646e6 Revert adding FBchatRedirectError 2018-06-27 11:14:55 +02:00
Mads Marquart
0947e77082 Fixed FBchatRedirectError 2018-06-27 11:07:16 +02:00
Mads Marquart
637b0ded09 Added FBchatRedirectError 2018-06-27 10:45:11 +02:00
Mads Marquart
9b7a84ea45 Added more debug info, to fix a wierd bug 2018-06-26 10:40:01 +02:00
Mads Marquart
ead696cbad Attempted to improve TravisCI online tests 2018-06-24 12:20:17 +02:00
Mads Marquart
da23ad5eb5 Merge branch 'test_travis_config' 2018-06-21 21:42:54 +02:00
Mads Marquart
b63a0dfa01 Made the offline tests colorful ;) 2018-06-21 21:38:52 +02:00
Mads Marquart
6c00724a84 Removed unnecessary env 2018-06-21 21:30:58 +02:00
Mads Marquart
7619224809 Removed travis_fold test 2018-06-21 21:29:11 +02:00
Mads Marquart
e0d3dd9050 New TravisCI setup, using build stages 2018-06-21 21:13:17 +02:00
Mads Marquart
71bf5e0e4f Added CONTRIBUTING.rst 2018-06-21 17:12:01 +02:00
Mads Marquart
540e530420 Added Contributor Covenant Code of Conduct 2018-06-21 17:11:46 +02:00
Mads Marquart
070a8cad15 Removed wrong templates 2018-06-21 16:52:21 +02:00
Mads Marquart
5d094b38b0 Merge pull request #305 from carpedm20/issue_templates
Add issue templates via. Github's `Create Issue Template` feature
2018-06-21 16:42:25 +02:00
Mads Marquart
af3d385ff5 Add issue templates via. Github's Create Issue Template feature 2018-06-21 16:41:27 +02:00
Mads Marquart
c352a0d698 Modified license, so it's correctly recognised by licensee
It _should_ be okay, since the modified version is less permissive
The only real addition is `Neither the name of the copyright holder nor`
2018-06-21 15:36:54 +02:00
Mads Marquart
060f64b4d2 Rename LICENSE.txt to LICENSE 2018-06-21 15:29:52 +02:00
Mads Marquart
4f032cd946 Fixed a few exception values, see #303 2018-06-21 15:23:43 +02:00
Mads Marquart
cee6039ec3 Prevent builds from failing the deploy [ci skip]
Every job runs the build stage, which is fine, since we need the different `wheel` packages, but they failed, since the files were already present on PyPI
2018-06-20 16:54:03 +02:00
Mads Marquart
c8f8b818e0 Version up, thanks to @orenyomtov and @ ThatAlexanderA
* Added `removeFriend` method, #298
* Removed `lxml` from dependencies, #301
* Moved configuration to setup.cfg instead of setup.py
2018-06-20 15:53:57 +02:00
Mads Marquart
08922ae284 Moved Travis account configuration into Travis Settings 2018-06-20 14:29:43 +02:00
Mads Marquart
51d606a54e Merge pull request #298 from ThatAlexanderA/master
Added remove friend
2018-06-20 14:29:00 +02:00
Mads Marquart
2b76d71c67 Merge branch 'master' into alexander_master 2018-06-20 13:51:32 +02:00
Mads Marquart
67edd19eb8 Small formatting fixes 2018-06-20 13:51:12 +02:00
Mads Marquart
eaaa526cfc Merge pull request #301 from orenyomtov/patch-4
Replace lxml with Python's built in html.parser
2018-06-20 13:46:56 +02:00
Mads Marquart
843c0f6c37 Merge branch 'master' into patch-4 2018-06-20 13:38:59 +02:00
Mads Marquart
44ebf38e47 Updated setup.py and requirements, now we use setup.cfg 2018-06-20 13:35:56 +02:00
Mads Marquart
d640e7d2ea Enabled pypy and pytest session caching, updated README 2018-06-19 13:49:10 +02:00
Oren
66736519ed Remove lxml dependency 2018-06-14 16:20:57 +03:00
Oren
73f4c98be9 Remove lxml dependency 2018-06-14 16:20:35 +03:00
Oren
b2ff7fefaa Replace lxml with Python's built in html.parser 2018-06-14 16:19:09 +03:00
2FWAH
2edb95dfdd Fetch missing users in a single request 2018-06-12 08:38:02 +02:00
2FWAH
e0bb9960fb Check if list is empty with if instead of len() 2018-06-12 08:15:53 +02:00
2FWAH
71608845c0 Use snake case convention 2018-06-12 07:55:16 +02:00
2FWAH
0048e82151 Fix typo in fetchAllUsersFromThreads 2018-06-07 21:58:00 +02:00
Mads Marquart
6116bc9ca4 addUsersToGroup can no longer return the message id
Updated documentation and tests
2018-06-06 16:39:23 +02:00
ThatAlexanderA
c7cbbdd1c8 Changed dict to query, replaced print with log 2018-06-05 21:56:31 +02:00
ThatAlexanderA
b599033c54 Updated removeFirend 2018-06-05 18:41:09 +02:00
Mads Marquart
7bf6a9fadc Version up, thanks to @2FWAH
* Fixed `onTyping`
* Fixed `changeThreadColor` with `MESSENGER_BLUE `
2018-06-05 13:17:46 +02:00
Mads Marquart
4490360e11 Changed encrypted passwords to point to the free TravisCI version 2018-06-05 13:16:14 +02:00
Mads Marquart
a4dfe0d279 changeThreadColor now works with MESSENGER_BLUE again 2018-06-05 12:55:03 +02:00
Mads Marquart
47679d1d3b Merge remote-tracking branch '2FWAH/fix-ontyping' 2018-06-05 12:51:43 +02:00
Mads Marquart
62e17daf78 thread_fbid is not available with typ, there thread_id = author_id
Also enabled tests
2018-06-04 23:57:50 +02:00
2FWAH
1f359f2a72 Call onTyping on "typ" or "ttyp" messages
FB returns "typ" for ONE-TO-ONE conversations and "ttyp" for GROUP conversations.
2018-06-04 23:25:50 +02:00
2FWAH
cebe7a28c0 Fix onTyping detection
FB changed the format of typing notification messages:
- update "mtype" from "typ" to "ttyp".
- Get thread ID from "to" to "thread_fbid" ("thread" looks the same)
2018-06-04 23:25:50 +02:00
ThatAlexanderA
91778f43b7 Update client.py 2018-06-04 16:19:40 +02:00
ThatAlexanderA
e3602e83ce Added Remove Friend URl 2018-06-04 16:18:32 +02:00
ThatAlexanderA
36742bf30b Added remove friend def 2018-06-04 16:16:53 +02:00
Mads Marquart
e614800d5f Update encrypted passwords 2018-06-04 13:57:21 +02:00
Mads Marquart
151a114235 TravisCI integration and updated test suite (#296)
* Make TravisCI setup

* Use pytest, move tests to seperate files

* Added system to check if `onX` events were successfully executed
2018-06-04 13:44:04 +02:00
ThatAlexanderA
c842be3a52 Update client.py 2018-06-04 13:32:15 +02:00
ThatAlexanderA
a264fac2b4 Update utils.py 2018-06-04 13:29:23 +02:00
2FWAH
0767ef4902 Add fetchAllUsersFromThreads
Add a method to get all users involved in threads (given as a parameter)
2018-06-01 23:27:34 +02:00
2FWAH
abe3357e67 Explicit parameter thread_location 2018-06-01 23:08:03 +02:00
2FWAH
19457efe9b Fix call to fetchThreadList
Use "self" instead of "client"
2018-06-01 23:06:02 +02:00
2FWAH
487a2eb3e3 Add fetchThreads method
Add a method to get all threads in Location (INBOX, ARCHIVED...)
2018-06-01 22:59:56 +02:00
Mads Marquart
38f66147cb Version up, thanks to @orenyomtov and @Abhinav2812
Also fixed `Client.isLoggedIn`
2018-05-18 17:35:03 +02:00
Mads Marquart
ffa26c20b5 Merge branch 'patch-1' 2018-05-18 16:58:32 +02:00
Abhinav2812
430ada7f84 Resolve FBChatException
Resolve the error `fbchat.models.FBchatException: Could not get ThreadColor from color: FF0084FF` when threadcolor is set to default (MESSENGER_BLUE)
2018-05-16 17:54:37 +05:30
Mads Marquart
988e37eb42 Merge remote-tracking branch 'orenyomtov/patch-3' 2018-05-08 16:51:03 +02:00
Mads Marquart
1938b90bce Merge remote-tracking branch 'orenyomtov/patch-2' 2018-05-08 16:50:56 +02:00
Mads Marquart
f61d1403f3 Merge remote-tracking branch 'orenyomtov/patch-1' 2018-05-08 16:50:48 +02:00
Oren
d228f34f64 Eliminate an unnecessary HTTP request during login
This change eliminates requesting and downloading the entire FB home page (~160kb) every login.
2018-05-08 15:40:46 +03:00
Oren
97049556ed Update obtaining fb_dtsg and fb_h
fb_dtsg is sometimes returned inside an HTML comment, and beautifulsoup can't find it - in that case we'll use a regular expression to extract it.

fb_h is sometimes not returned in the HTML of req_url.BASE (in my experience, when resuming a session using session_cookies).

Following the discussion here:
https://github.com/Schmavery/facebook-chat-api/issues/505
I learned it is used for logging out, and can be found in the response of `https://www.facebook.com/bluebar/modern_settings_menu/`.

I included support for fetching it from there.

Because this library is used many more times for logging in, than for logging out, instead of adding an extra HTTP request during login, I decided to perform it during logout, only in case fb_h is not found in the HTML of req_url.BASE.
2018-05-08 12:41:22 +03:00
Oren
b64c6a94cc Add MODERN_SETTINGS_MENU url to ReqUrl
It is used to obtain the fb_h value
2018-05-08 12:18:15 +03:00
Oren
edc655bae7 Fix IndexError: list index out of range bug
When the returned `short_name` is null, `fbchat` throws an exception:

```python
  File "/usr/local/lib/python2.7/site-packages/fbchat/client.py", line 792, in fetchThreadList
    return [graphql_to_thread(node) for node in j['viewer']['message_threads']['nodes']]
  File "/usr/local/lib/python2.7/site-packages/fbchat/graphql.py", line 193, in graphql_to_thread
    last_name=user.get('name').split(user.get('short_name'),1)[1].strip(),
IndexError: list index out of range
```

This commit fixes that scenario by accessing the last item in the list via `.pop()` instead of via `[1]`
2018-05-07 19:50:43 +03:00
Mads Marquart
884af48270 Version up, thanks to @gave92
Properly fixed `markAsRead`, @gave92  reminded me that I forgot to change the `True` to `'true'` when removing `encode_params`
2018-03-21 10:05:07 +01:00
Mads Marquart
95f018fad3 Fixed example Echobot 2018-03-19 21:40:51 +01:00
Mads Marquart
b44758a195 Version up, thanks to @gave92
Fix `markAsRead` and `fetchUnread`; fixes #261
Added the `ssl_verify` instance variable, which allows disabling SSL varification for proxies
2018-03-19 21:28:48 +01:00
Mads Marquart
f1c20d490e Removed encode_params from PR, as discussed in #269 2018-03-19 21:15:23 +01:00
Mads Marquart
04372d498e Merge pull request #269 from gave92/FetchUnread
Fix `markAsRead` and `fetchUnread`; fixes #261
Added the `ssl_verify` instance variable, which allows disabling SSL varification for proxies
2018-03-19 21:08:45 +01:00
Marco Gavelli
63ea899605 fix for python3 2018-03-19 20:47:41 +01:00
Marco Gavelli
4fdd145d1e verify in _postFile 2018-03-19 16:52:22 +01:00
Marco Gavelli
57ee68b0e0 added documentation to markAsRead 2018-03-19 16:38:19 +01:00
Marco Gavelli
99c6884681 added documentation to fetchUnread 2018-03-19 16:29:26 +01:00
Marco Gavelli
1c1438e9bc fix for markAsRead, fetchUnread 2018-03-18 11:18:46 +01:00
Marco Gavelli
22f1b3e489 fix FetchUnread 2018-03-17 19:32:45 +01:00
Mads Marquart
fb1ad5800c Minor fix for searchFor. See comments on #266 2018-03-05 22:07:16 +01:00
Taehoon Kim
4dd15b05ef version up thanks to @2FWAH's PR #266 #267 2018-03-03 22:49:25 +09:00
Taehoon Kim
d7cdb644c4 Merge pull request #265 from 2FWAH/fix-fetchThreadList-archived
Fix ThreadLocation to work with new GraphQL and archived threads
2018-03-03 22:22:21 +09:00
Taehoon Kim
bfcf4950b3 Merge pull request #266 from 2FWAH/fill-last_message_timestamp-in-fetchThreadList
Add last_message_timestamp support
2018-03-03 22:21:49 +09:00
Taehoon Kim
6612c97f05 Merge pull request #267 from danijeljw/patch-1
duplicate lines removed from setup
2018-03-03 22:20:28 +09:00
Danijel-James Wynyard
b92cf62726 duplicate lines removed 2018-03-03 12:08:05 +11:00
2FWAH
a53ba33a81 Set offset to 'None' by default 2018-02-23 09:23:34 +01:00
2FWAH
c04d38cf63 Handle last_message_timestamp
Set last_message_timestamp for one to one and group conversations.
2018-02-22 19:53:56 +01:00
2FWAH
a051adcbc0 Fix ThreadLocation to work with new GraphQL 2018-02-22 17:49:26 +01:00
Mads Marquart
900a9cdf72 Version up, thanks to @gave92
`fetchThreadList` is updated with a GraphQL implementation. See #241
2018-02-18 22:40:13 +01:00
Mads Marquart
611b329934 Merge pull request #259 from gave92/fetchThreadListGraphQL
Added GraphQL alternative to fetchThreadList; fixes #241
2018-02-18 22:36:23 +01:00
Mads Marquart
2642788bc1 Merged fetchThreadListGraphQL into fetchThreadList 2018-02-18 22:32:12 +01:00
Marco Gavelli
8268445f0b Changed return type for ONE_TO_ONE to User 2018-02-18 22:49:47 +01:00
Marco Gavelli
c12dcd9263 Added GraphQL alternative to fetchThreadList; fixes #241 2018-02-17 14:29:31 +01:00
Mads Marquart
3142524809 Version up, thanks to @DeltaF1
`onFriendRequest` functionality is restored
2018-02-07 11:30:19 +01:00
Mads Marquart
4c9d3bd9d7 Merge pull request #255 from DeltaF1/master
Restored onFriendRequest functionality
2018-02-07 11:29:04 +01:00
DeltaF1
ba103066b8 Restored onFriendRequest functionality 2018-02-06 00:30:35 -05:00
Mads Marquart
0b0d6179a2 Version up, thanks to @sdnian
`fetchThreadMessages` and `listen` can now parse AudioAttachments
2018-01-30 17:20:47 +01:00
Mads Marquart
e8806d4ef8 Merge pull request #254 from sdnian/bransh1
modify AudioAttachment function
2018-01-30 17:15:55 +01:00
Steve Nian
c96e5f174c update 2018-01-30 20:22:18 +08:00
Steve Nian
315242e069 update 2018-01-30 20:17:09 +08:00
Steve Nian
a94fa5fbe3 AudioAttachment 2018-01-30 17:33:29 +08:00
Mads Marquart
90203afdd0 Fixes documentation error 2018-01-23 20:20:13 +01:00
Mads Marquart
2c0d098852 Fixes #240, small backwards-compatablitity issue when sending images 2018-01-08 21:55:11 +01:00
Mads Marquart
e4290cd465 Version up, thanks to @lobstr 2018-01-02 13:40:50 +01:00
Mads Marquart
46b85dec5c Merge remote-tracking branch 'lobstr/master' 2018-01-02 13:40:25 +01:00
Mads Marquart
bbc34bd009 Added onTyping method 2018-01-02 13:33:13 +01:00
cirrux
c495317e65 Fix setTypingStatus to send correctly 2018-01-01 23:11:35 -05:00
cirrux
a946050228 Re-enable typing notification 2017-12-31 12:27:55 -05:00
cirrux
83789dcefa Fix attachment parsing for newer structure 2017-12-26 19:12:10 -05:00
Mads Marquart
4f1f9bf1ce Fixed errors on unknown genders 2017-12-15 23:46:47 +01:00
Mads Marquart
32c72c2f35 Version up, thanks to @Dante383 2017-12-10 20:08:13 +01:00
Dante
42ae0035af typo in function name
checkRequest --> check_request
2017-12-10 14:16:17 +01:00
Mads Marquart
96e28fdbe6 Fixed error when recieving share attachments 2017-11-16 15:14:46 +01:00
Taehoon Kim
0f889f50cf Update README.rst 2017-11-14 17:25:01 +09:00
Mads Marquart
478eaebdec Removed copyright icon from README.rst, fixing #219 2017-10-21 18:47:12 +02:00
Mads Marquart
7ecf229db5 See commit description
- Deprecated `sendMessage` and `sendEmoji` in favor of `send`
- (Almost) Fully integrated attachment support
- Updated tests
- General cleanup
2017-10-21 17:59:44 +02:00
Mads Marquart
dda75c6099 Merge remote-tracking branch 'svaikstude/feature/@mention' 2017-10-21 12:10:58 +02:00
Mads Marquart
28d5ac9f90 Merge branch 'attachment-support' 2017-10-21 12:09:52 +02:00
Mads Marquart
52acfb4636 Removed json from main client.py 2017-10-21 11:58:57 +02:00
Mads Marquart
2a64bad385 Merge branch 'feature/eventReminder' 2017-10-21 11:49:37 +02:00
Mads Marquart
1a73699f1a Merge branch 'mutable-default' 2017-10-21 11:48:06 +02:00
Mads Marquart
1b5a7a0063 Merge branch 'rooms' 2017-10-21 11:46:28 +02:00
ekohilas
4b3eb440cf fixed missing get 2017-10-20 03:17:26 +11:00
ekohilas
d1f457866b fixed dict typo 2017-10-20 03:08:27 +11:00
ekohilas
6f29aa82cb fixed class mistype 2017-10-15 15:15:56 +11:00
ekohilas
b1a2ff7d84 updated for python2.7 2017-10-15 03:56:09 +11:00
ekohilas
883b16e251 fixed simple merge 2017-10-08 03:32:20 +11:00
ekohilas
116b39cf6a fixed superclass init error 2017-10-08 03:25:25 +11:00
ekohilas
eae1db9c7d removed list and rstrip 2017-10-08 03:25:25 +11:00
ekohilas
730bab5d40 added rooms under thread_info 2017-10-08 03:25:10 +11:00
ekohilas
d52dac233e made appropriate changes to default args of rooms 2017-10-08 03:22:15 +11:00
ekohilas
1f37277a8d started adding rooms 2017-10-08 03:22:15 +11:00
ekohilas
15014d7055 merging from upstream
Merge remote-tracking branch 'upstream/master'
2017-10-07 15:06:31 +11:00
ekohilas
7a35ca05b1 fixed all mutable default argument gotchas 2017-10-07 14:15:45 +11:00
svaikstude
be6b6909d9 Update client.py 2017-10-06 17:05:09 +02:00
Manvydas Kriauciunas
42c1d26b2e new feature to support @mention in sendMessage 2017-10-06 16:31:25 +02:00
Manvydas Kriauciunas
d38f8ad2ec new feature eventReminder added 2017-10-06 16:23:47 +02:00
Mads Marquart
023fd58f05 Version up, thanks to @ekohilas 2017-10-03 22:46:04 +02:00
Mads Marquart
ad10a8f07f Merge pull request #213 from ekohilas/requirements_fix
Updates setup.py for better compatibility with setuptools
2017-10-03 22:44:56 +02:00
Mads Marquart
7d6cf039d4 Merge branch 'master' into requirements_fix 2017-10-03 22:44:03 +02:00
ekohilas
f0271e17b0 updated for older setuptools 2017-10-04 01:57:02 +11:00
Mads Marquart
57954816b2 Version up, thanks to @WeiTang114 2017-10-03 08:29:34 +02:00
Mads Marquart
3e4e1f9bb9 Merge pull request #212 from WeiTang114/gif_support_2
Add Gif support to send(Local/Remote)Image
2017-10-03 08:26:10 +02:00
Mads Marquart
7340918209 Merge pull request #211 from WeiTang114/fetch_pending_thread_2
Enable fetching pending/archived threads
2017-10-03 08:25:58 +02:00
Tang
707df4f941 use mimetype to see if it's a GIF
thanks to @madsmtm's good idea
2017-10-03 03:29:15 +08:00
Tang
8eb6b83411 Update for feedback by @madsmtm
1. Add ThreadLocation Enum in models.
2. avoid using build-in name "type" as parameter name
3. replace ValueError with FBchatUserError

thanks to @madsmtm
2017-10-03 03:05:08 +08:00
Tang
e0aedd617b add param is_gif to doc of functions 2017-10-03 01:40:19 +08:00
Tang
ee81620c14 Add send GIF images support
When uploading and sending GIF images, the keys are explicitly changed
to "gif_id" or "gif_ids" rather than "image_id" or "image_ids".
2017-10-03 01:39:20 +08:00
Tang
2d027af71a Enable fetching pending/archived threads
Add "type" parameter to fetchThreadList().
type can be 'inbox', 'pending' or 'archived'

If set to 'pending', it can fetch messages from unknown users.
It is quite useful to build a service accepting requests from anyone.

For example, in doOneListen(), fetch pending messages once for a while
to handle the messages from strangers.
2017-10-03 01:37:25 +08:00
Mads Marquart
9d5f06b810 Fixed pip setup 2017-09-30 19:17:40 +02:00
Mads Marquart
b8fdcda2fb Properly uploading requirements (pip requires changed version number) 2017-09-30 01:15:41 +02:00
Mads Marquart
0dac7b7b81 Version up, thanks to @ekohilas 2017-09-27 21:20:20 +02:00
Mads Marquart
b750e753d6 Merge pull request #206 from ekohilas/master
Fixes 2FA bug and updates pip requirements
2017-09-27 21:19:14 +02:00
ekohilas
ee33e92bed added conditional enum34 2.7 requirement 2017-09-27 19:24:35 +10:00
ekohilas
7413a643f6 fixed 2FA bug 2017-09-27 19:23:58 +10:00
Mads Marquart
34452f9220 Changed API description 2017-09-26 16:49:10 +02:00
Mads Marquart
24831b2462 Merge branch 'master' into attachment-support 2017-09-26 16:45:47 +02:00
Mads Marquart
cd4a18cb5a Version up 2017-09-25 20:02:35 +02:00
Mads Marquart
c00b3df8b2 Merge pull request #201 from madsmtm/improved-stability
Possibly fixes #175, added custom Exception classes
2017-09-25 20:01:23 +02:00
Mads Marquart
1beb821b2c Added function to fetch url from image id
Fixes #84
2017-09-21 23:58:50 +02:00
Mads Marquart
a58791048a Added attachment and mention support in onMessage
Deprecated `message` in `onMessage`
2017-09-21 22:32:11 +02:00
Mads Marquart
f0c6e8612f Fixed typo and made name more generic 2017-09-21 10:09:48 +02:00
Mads Marquart
1cebbf92e6 Fixed loading sessions 2017-09-20 11:31:44 +02:00
Mads Marquart
a64982583b Fixes 502/503 errors and a the 1357004 error
Thereby also moving ReqUrl to self.req_url
2017-09-19 23:08:48 +02:00
Mads Marquart
cb8b0915de Improved default doOneListen loop 2017-09-19 16:42:03 +02:00
Mads Marquart
1d2576b06d More custom exceptions 2017-09-19 16:36:24 +02:00
Mads Marquart
ead9a3c0e9 Improved error handling, and improved uid-loading
Requests would sometimes throw an error while retrieving the c_user cookie (If there were multiple cookies with this name)
2017-09-19 16:36:08 +02:00
Mads Marquart
59ba418faa Added custom exceptions
Added `FBchatException`, `FBchatFacebookError` and `FBchatUserError`, which can help in differentiating between errors
2017-09-19 16:31:53 +02:00
Mads Marquart
c51a332560 Version up, thanks to @PythonNut 2017-08-27 23:03:50 +02:00
Mads Marquart
a73d2feed6 Merge pull request #193 from PythonNut/master
Fix UNKNOWN gender in graphql requests
2017-08-27 23:02:05 +02:00
PythonNut
6929193e9d Fix UNKNOWN gender in graphql requests 2017-08-13 23:10:11 +00:00
Mads Marquart
fea4ad9e89 Version up, Thanks to ritu99 2017-08-10 15:25:38 +02:00
Mads Marquart
68099049d4 Merge pull request #189 from ritu99/master
Added Message Count to thread information
2017-08-10 15:22:22 +02:00
Ritvik Annam
44cf08bdfd fetchThreadInfo now pulls message_count 2017-08-10 01:15:29 -05:00
Ritvik Annam
9e32cf17a4 fetchThreadList now pulls message_count 2017-08-10 00:53:06 -05:00
Mads Marquart
0661367ebb Properly fixed #182 2017-08-02 23:08:34 +02:00
Mads Marquart
3c07e42ba2 Version up, fixed #182 2017-07-26 23:13:19 +02:00
Mads Marquart
2cd6376818 Merge pull request #178 from Bankde/fix-fail-after-running-for-days
Fix issue when running for long time
2017-07-26 23:03:49 +02:00
Mads Marquart
5e7f7750de Fixed enums in python 2.7, thanks to @liamkirsh 2017-07-12 14:52:15 +02:00
Bankde@hotmail.com
2a223ec6db fix array indexing (I don't know why fb do that) 2017-07-10 10:25:23 +07:00
Mads Marquart
a99108fff6 Version up thanks to @Bankde 2017-07-09 20:55:06 +02:00
Mads Marquart
8de4698cc4 Merge pull request #174 from Bankde/fix-error-in-python2
No FileNotFoundError in py2
2017-07-09 20:53:47 +02:00
Bankde@hotmail.com
637319ec2c add token update 2017-07-10 00:51:51 +07:00
Bankde@hotmail.com
f9398564cd replace FileNotFoundError with IOError so it can work in Py2 2017-07-05 09:18:13 +07:00
Mads Marquart
b57f423eb4 Version up thanks to @aaronlewism 2017-07-01 12:48:02 +02:00
Mads Marquart
3093f1f2b6 Merge pull request #173 from aaronlewism/master
Check for alternate 2Factor page text
2017-07-01 12:46:11 +02:00
Aaron Lewis
961777e0c1 Check for alternate 2Factor page text 2017-06-29 13:21:25 -07:00
Mads Marquart
d7139701f7 Fixed typo, improved formatting. Thanks to @JarbasAI! 2017-06-29 20:04:01 +02:00
Mads Marquart
c6bac17d48 Merge pull request #172 from JarbasAI/patch-1
Add on chat presence event
2017-06-29 19:55:49 +02:00
Mads Marquart
3638fc5356 Made fetchThreadInfo able to fetch own user's info 2017-06-29 19:53:29 +02:00
Jarbas
aca9176f7f Add on chat presence event
Last_seen time stamps were handled in unknown message type, this info is freely available and potentially useful
2017-06-29 17:56:14 +01:00
Mads Marquart
0d5e4f6d3f Version up thanks to @enwar3 2017-06-29 16:03:55 +02:00
Mads Marquart
92a5ffdef8 Merge pull request #170 from OMGWINNING/master
Add extensible_attachment field to Message for fb share objects
2017-06-29 16:02:27 +02:00
Joe Lau
b3359fccdb Add last_message_timestamp to Thread objects 2017-06-28 18:08:45 -07:00
Joe Lau
d8f7366d1f Add extensible_attachment field to Message for fb share objects 2017-06-28 13:19:17 -07:00
115 changed files with 10959 additions and 3381 deletions

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

@@ -0,0 +1,34 @@
---
name: Bug report
about: Create a report if you're having trouble with `fbchat`
---
## Description of the problem
Example: Logging in fails when the character `%` is in the password. A specific password that fails is `a_password_with_%`
## Code to reproduce
```py
# Example code
from fbchat import Client
client = Client("[REDACTED_USERNAME]", "a_password_with_%")
```
## Traceback
```
Traceback (most recent call last):
File "<test.py>", line 1, in <module>
File "[site-packages]/fbchat/client.py", line 78, in __init__
self.login(email, password, max_tries)
File "[site-packages]/fbchat/client.py", line 407, in login
raise FBchatException('Login failed. Check email/password. (Failed on URL: {})'.format(login_url))
fbchat.FBchatException: Login failed. Check email/password. (Failed on URL: https://m.facebook.com/login.php?login_attempt=1)
```
## Environment information
- Python version
- `fbchat` version
- If relevant, output from `$ python -m pip list`
If you have done any research, include that.
Make sure to redact all personal information.

View File

@@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest a feature that you'd like to see implemented
---
## Description
Example: There's no way to send messages to groups
## Research (if applicable)
Example: I've found the URL `https://facebook.com/send_message.php`, to which you can send a POST requests with the following JSON:
```json
{
"text": message_content,
"fbid": group_id,
"some_variable": ?
}
```
But I don't know how what `some_variable` does, and it doesn't work without it. I've found some examples of `some_variable` to be: `MTIzNDU2Nzg5MA`, `MTIzNDU2Nzg5MQ` and `MTIzNDU2Nzg5Mg`

15
.gitignore vendored
View File

@@ -8,9 +8,11 @@
# Packages
*.egg
*.egg-info
*.dist-info
dist
build
eggs
.eggs
parts
bin
var
@@ -24,7 +26,16 @@ develop-eggs
# Sphinx documentation
docs/_build/
# Data for tests
# Scripts and data for tests
my_tests.py
my_test_data.json
my_data.json
tests.data
tests.data
.pytest_cache
# MyPy
.mypy_cache/
# Virtual environment
venv/
.venv*/

18
.readthedocs.yml Normal file
View File

@@ -0,0 +1,18 @@
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
formats:
- pdf
- htmlzip
python:
version: 3.6
install:
- path: .
extra_requirements:
- docs
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
fail_on_warning: true

53
.travis.yml Normal file
View File

@@ -0,0 +1,53 @@
sudo: false
language: python
python: 3.6
cache: pip
before_install: pip install flit
# Use `--deps production` so that we don't install unnecessary dependencies
install: flit install --deps production --extras test
script: pytest
jobs:
include:
- python: 3.5
- python: 3.6
- python: 3.7
- python: pypy3.5
- name: Lint
before_install: skip
install: pip install black
script: black --check --verbose .
- stage: deploy
name: GitHub Releases
if: tag IS present
install: skip
script: flit build
deploy:
provider: releases
api_key: $GITHUB_OAUTH_TOKEN
file_glob: true
file: dist/*
skip_cleanup: true
draft: false
on:
tags: true
- stage: deploy
name: PyPI
if: tag IS present
install: skip
script: skip
deploy:
provider: script
script: flit publish
on:
tags: true
notifications:
email:
on_success: never
on_failure: change

75
CODE_OF_CONDUCT Normal file
View File

@@ -0,0 +1,75 @@
Contributor Covenant Code of Conduct
Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at carpedm20@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the projects leadership.
Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html

42
CONTRIBUTING.rst Normal file
View File

@@ -0,0 +1,42 @@
Contributing to ``fbchat``
==========================
Thanks for reading this, all contributions are very much welcome!
Please be aware that ``fbchat`` uses `Scemantic Versioning <https://semver.org/>`__ quite rigorously!
That means that if you're submitting a breaking change, it will probably take a while before it gets considered.
Development Environment
-----------------------
This project uses ``flit`` to configure development environments. You can install it using:
.. code-block:: sh
$ pip install flit
And now you can install ``fbchat`` as a symlink:
.. code-block:: sh
$ git clone https://github.com/carpedm20/fbchat.git
$ cd fbchat
$ # *nix:
$ flit install --symlink
$ # Windows:
$ flit install --pth-file
This will also install required development tools like ``black``, ``pytest`` and ``sphinx``.
After that, you can ``import`` the module as normal.
Checklist
---------
Once you're done with your work, please follow the steps below:
- Run ``black .`` to format your code.
- Run ``pytest`` to test your code.
- Run ``make -C docs html``, and view the generated docs, to verify that the docs still work.
- Run ``make -C docs spelling`` to check your spelling in docstrings.
- Create a pull request, and point it to ``master`` `here <https://github.com/carpedm20/fbchat/pulls/new>`__.

View File

@@ -1,4 +1,4 @@
New BSD License
BSD 3-Clause License
Copyright (c) 2015, Taehoon Kim
All rights reserved.
@@ -13,8 +13,9 @@ modification, are permitted provided that the following conditions are met:
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* The names of its contributors may not be used to endorse or promote products
derived from this software without specific prior written permission.
* Neither the name of the copyright holder 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

View File

@@ -1,4 +0,0 @@
include LICENSE.txt
include MANIFEST.in
include README.rst
include setup.py

View File

@@ -1,30 +1,112 @@
fbchat: Facebook Chat (Messenger) for Python
============================================
``fbchat`` - Facebook Messenger for Python
==========================================
.. image:: https://img.shields.io/badge/license-BSD-blue.svg
:target: LICENSE.txt
:alt: License: BSD
.. image:: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6-blue.svg
.. image:: https://badgen.net/pypi/v/fbchat
:target: https://pypi.python.org/pypi/fbchat
:alt: Supported python versions: 2.7, 3.4, 3.5 and 3.6
:alt: Project version
.. image:: https://readthedocs.org/projects/fbchat/badge/?version=master
.. image:: https://badgen.net/badge/python/3.5,3.6,3.7,3.8,pypy?list=|
:target: https://pypi.python.org/pypi/fbchat
:alt: Supported python versions: 3.5, 3.6, 3.7, 3.8 and pypy
.. image:: https://badgen.net/pypi/license/fbchat
:target: https://github.com/carpedm20/fbchat/tree/master/LICENSE
:alt: License: BSD 3-Clause
.. image:: https://readthedocs.org/projects/fbchat/badge/?version=stable
:target: https://fbchat.readthedocs.io
:alt: Documentation
Facebook Chat (`Messenger <https://www.facebook.com/messages/>`__) for Python.
This project was inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`__.
.. image:: https://badgen.net/travis/carpedm20/fbchat
:target: https://travis-ci.org/carpedm20/fbchat
:alt: Travis CI
**No XMPP or API key is needed**. Just use your email and password.
.. image:: https://badgen.net/badge/code%20style/black/black
:target: https://github.com/ambv/black
:alt: Code style
Go to `Read the Docs <https://fbchat.readthedocs.io>`__ to see the full documentation,
or jump right into the code by viewing the `examples <examples>`__
A powerful and efficient library to interact with
`Facebook's Messenger <https://www.facebook.com/messages/>`__, using just your email and password.
Installation:
This is *not* an official API, Facebook has that `over here <https://developers.facebook.com/docs/messenger-platform>`__ for chat bots. This library differs by using a normal Facebook account instead.
.. code-block:: console
``fbchat`` currently support:
- Sending many types of messages, with files, stickers, mentions, etc.
- Fetching all messages, threads and images in threads.
- Searching for messages and threads.
- Creating groups, setting the group emoji, changing nicknames, creating polls, etc.
- Listening for, an reacting to messages and other events in real-time.
- Type hints, and it has a modern codebase (e.g. only Python 3.5 and upwards).
- ``async``/``await`` (COMING).
Essentially, everything you need to make an amazing Facebook bot!
Version Warning
---------------
``v2`` is currently being developed at the ``master`` branch and it's highly unstable. If you want to view the old ``v1``, go `here <https://github.com/carpedm20/fbchat/tree/v1>`__.
Additionally, you can view the project's progress `here <https://github.com/carpedm20/fbchat/projects/2>`__.
Caveats
-------
``fbchat`` works by imitating what the browser does, and thereby tricking Facebook into thinking it's accessing the website normally.
However, there's a catch! **Using this library may not comply with Facebook's Terms Of Service!**, so be responsible Facebook citizens! We are not responsible if your account gets banned!
Additionally, **the APIs the library is calling is undocumented!** In theory, this means that your code could break tomorrow, without the slightest warning!
If this happens to you, please report it, so that we can fix it as soon as possible!
.. inclusion-marker-intro-end
.. This message doesn't make sense in the docs at Read The Docs, so we exclude it
With that out of the way, you may go to `Read The Docs <https://fbchat.readthedocs.io/>`__ to see the full documentation!
.. inclusion-marker-installation-start
Installation
------------
.. code-block::
$ pip install fbchat
© Copyright 2015 - 2017 by Taehoon Kim / `@carpedm20 <http://carpedm20.github.io/about/>`__
If you don't have `pip <https://pip.pypa.io/>`_, `this guide <http://docs.python-guide.org/en/latest/starting/installation/>`_ can guide you through the process.
You can also install directly from source, provided you have ``pip>=19.0``:
.. code-block::
$ pip install git+https://github.com/carpedm20/fbchat.git
.. inclusion-marker-installation-end
Example Usage
-------------
.. code-block::
import getpass
import fbchat
session = fbchat.Session.login("<email/phone number>", getpass.getpass())
user = fbchat.User(session=session, id=session.user_id)
user.send_text("Test message!")
More examples are available `here <https://github.com/carpedm20/fbchat/tree/master/examples>`__.
Maintainer
----------
- Mads Marquart / `@madsmtm <https://github.com/madsmtm>`__
Acknowledgements
----------------
This project was originally inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`__.

View File

@@ -3,8 +3,7 @@
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = python3.6 -msphinx
SPHINXPROJ = fbchat
SPHINXBUILD = sphinx-build
SOURCEDIR = .
BUILDDIR = _build

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="a"><rect width="80" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#a)"><path fill="#555" d="M0 0h47v20H0z"/><path fill="#007ec6" d="M47 0h33v20H47z"/><path fill="url(#b)" d="M0 0h80v20H0z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="23.5" y="15" fill="#010101" fill-opacity=".3">license</text><text x="23.5" y="14">license</text><text x="62.5" y="15" fill="#010101" fill-opacity=".3">BSD</text><text x="62.5" y="14">BSD</text></g></svg>

Before

Width:  |  Height:  |  Size: 791 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="154" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="a"><rect width="154" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#a)"><path fill="#555" d="M0 0h49v20H0z"/><path fill="#007ec6" d="M49 0h105v20H49z"/><path fill="url(#b)" d="M0 0h154v20H0z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="24.5" y="15" fill="#010101" fill-opacity=".3">python</text><text x="24.5" y="14">python</text><text x="100.5" y="15" fill="#010101" fill-opacity=".3">2.7, 3.4, 3.5, 3.6</text><text x="100.5" y="14">2.7, 3.4, 3.5, 3.6</text></g></svg>

Before

Width:  |  Height:  |  Size: 825 B

View File

@@ -1,44 +0,0 @@
.. module:: fbchat
.. highlight:: python
.. _api:
Full API
========
If you are looking for information on a specific function, class, or method, this part of the documentation is for you.
.. _api_client:
Client
------
This is the main class of `fbchat`, which contains all the methods you use to interract with Facebook.
You can extend this class, and overwrite the events, to provide custom event handling (mainly used while listening)
.. autoclass:: Client(email, password, user_agent=None, max_tries=5, session_cookies=None, logging_level=logging.INFO)
:members:
.. _api_models:
Models
------
These models are used in various functions, both as inputs and return values.
A good tip is to write ``from fbchat.models import *`` at the start of your source, so you can use these models freely
.. automodule:: fbchat.models
:members:
:undoc-members:
.. _api_utils:
Utils
-----
These functions and values are used internally by fbchat, and are subject to change. Do **NOT** rely on these to be backwards compatible!
.. automodule:: fbchat.utils
:members:

13
docs/api/attachments.rst Normal file
View File

@@ -0,0 +1,13 @@
Attachments
===========
.. autoclass:: Attachment()
.. autoclass:: ShareAttachment()
.. autoclass:: Sticker()
.. autoclass:: LocationAttachment()
.. autoclass:: LiveLocationAttachment()
.. autoclass:: FileAttachment()
.. autoclass:: AudioAttachment()
.. autoclass:: ImageAttachment()
.. autoclass:: VideoAttachment()
.. autoclass:: ImageAttachment()

4
docs/api/client.rst Normal file
View File

@@ -0,0 +1,4 @@
Client
======
.. autoclass:: Client

4
docs/api/events.rst Normal file
View File

@@ -0,0 +1,4 @@
Events
======
.. autoclass:: Listener

8
docs/api/exceptions.rst Normal file
View File

@@ -0,0 +1,8 @@
Exceptions
==========
.. autoexception:: FacebookError()
.. autoexception:: HTTPError()
.. autoexception:: ParseError()
.. autoexception:: ExternalError()
.. autoexception:: GraphQLError()

21
docs/api/index.rst Normal file
View File

@@ -0,0 +1,21 @@
.. module:: fbchat
.. Note: we're using () to hide the __init__ method where relevant
Full API
========
If you are looking for information on a specific function, class, or method, this part of the documentation is for you.
.. toctree::
:maxdepth: 1
session
client
threads
thread_data
messages
exceptions
attachments
events
misc

8
docs/api/messages.rst Normal file
View File

@@ -0,0 +1,8 @@
Messages
========
.. autoclass:: Message
.. autoclass:: Mention
.. autoclass:: EmojiSize(Enum)
:undoc-members:
.. autoclass:: MessageData()

20
docs/api/misc.rst Normal file
View File

@@ -0,0 +1,20 @@
Miscellaneous
=============
.. autoclass:: ThreadLocation(Enum)
:undoc-members:
.. autoclass:: ActiveStatus()
.. autoclass:: QuickReply
.. autoclass:: QuickReplyText
.. autoclass:: QuickReplyLocation
.. autoclass:: QuickReplyPhoneNumber
.. autoclass:: QuickReplyEmail
.. autoclass:: Poll
.. autoclass:: PollOption
.. autoclass:: Plan
.. autoclass:: PlanData()
.. autoclass:: GuestStatus(Enum)
:undoc-members:

4
docs/api/session.rst Normal file
View File

@@ -0,0 +1,4 @@
Session
=======
.. autoclass:: Session()

6
docs/api/thread_data.rst Normal file
View File

@@ -0,0 +1,6 @@
Thread Data
===========
.. autoclass:: PageData()
.. autoclass:: UserData()
.. autoclass:: GroupData()

8
docs/api/threads.rst Normal file
View File

@@ -0,0 +1,8 @@
Threads
=======
.. autoclass:: ThreadABC()
.. autoclass:: Thread
.. autoclass:: Page
.. autoclass:: User
.. autoclass:: Group

View File

@@ -1,191 +1,194 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Configuration file for the Sphinx documentation builder.
#
# fbchat documentation build configuration file, created by
# sphinx-quickstart on Thu May 25 15:43:01 2017.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
# -- Path setup --------------------------------------------------------------
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath(".."))
os.environ["_FBCHAT_DISABLE_FIX_MODULE_METADATA"] = "1"
import fbchat
import tests
from fbchat import __copyright__, __author__, __version__, __description__
del os.environ["_FBCHAT_DISABLE_FIX_MODULE_METADATA"]
# -- Project information -----------------------------------------------------
project = fbchat.__name__
copyright = "Copyright 2015 - 2018 by Taehoon Kim and 2018 - 2020 by Mads Marquart"
author = "Taehoon Kim; Moreels Pieter-Jan; Mads Marquart"
description = fbchat.__doc__.split("\n")[0]
# The short X.Y version
version = fbchat.__version__
# The full version, including alpha/beta/rc tags
release = fbchat.__version__
# -- General configuration ------------------------------------------------
# -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
needs_sphinx = "2.0"
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.todo',
'sphinx.ext.viewcode'
"sphinx.ext.autodoc",
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
"sphinx.ext.napoleon",
"sphinxcontrib.spelling",
"sphinx_autodoc_typehints",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
templates_path = ["_templates"]
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'fbchat'
title = 'fbchat Documentation'
copyright = __copyright__
author = __author__
description = __description__
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = __version__
# The full version, including alpha/beta/rc tags.
release = __version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
master_doc = "index"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
rst_prolog = ".. currentmodule:: " + project
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
# The reST default role (used for this markup: `text`) to use for all
# documents.
#
default_role = "any"
# Make the reference parsing more strict
#
nitpicky = True
# Prefer strict Python highlighting
#
highlight_language = "python3"
# If true, '()' will be appended to :func: etc. cross-reference text.
#
add_function_parentheses = False
# -- Options for HTML output ----------------------------------------------
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
html_theme = "alabaster"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
html_theme_options = {
"show_powered_by": False,
"github_user": "carpedm20",
"github_repo": project,
"github_banner": True,
"show_related": False,
}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
html_sidebars = {"**": ["sidebar.html", "searchbox.html"]}
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#
html_show_sphinx = False
# If true, links to the reST sources are added to the pages.
#
html_show_sourcelink = False
# A shorter title for the navigation bar. Default is the same as html_title.
#
html_short_title = description
# -- Options for HTMLHelp output ------------------------------------------
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = project + 'doc'
htmlhelp_basename = project + "doc"
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# -- Options for LaTeX output ------------------------------------------------
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, project + '.tex', title,
author, 'manual'),
]
latex_documents = [(master_doc, project + ".tex", project, author, "manual")]
# -- Options for manual page output ---------------------------------------
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, project, title,
[author], 1)
]
man_pages = [(master_doc, project, project, [x.strip() for x in author.split(";")], 1)]
# -- Options for Texinfo output -------------------------------------------
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, project, title,
author, project, description,
'Miscellaneous'),
(master_doc, project, project, author, project, description, "Miscellaneous",)
]
# -- Options for Epub output -------------------------------------------------
# A list of files that should not be packed into the epub file.
epub_exclude_files = ["search.html"]
# -- Extension configuration -------------------------------------------------
# -- Options for autodoc extension ---------------------------------------
autoclass_content = "class"
autodoc_member_order = "bysource"
autodoc_default_options = {"members": True}
# -- Options for intersphinx extension ---------------------------------------
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'https://docs.python.org/3/': None}
intersphinx_mapping = {"https://docs.python.org/": None}
# -- Options for napoleon extension ----------------------------------------------
# Use Google style docstrings
napoleon_google_docstring = True
napoleon_numpy_docstring = False
add_function_parentheses = False
# napoleon_use_admonition_for_examples = False
# napoleon_use_admonition_for_notes = False
# napoleon_use_admonition_for_references = False
html_theme_options = {
'show_powered_by': False,
'github_user': 'carpedm20',
'github_repo': project,
'github_banner': True,
'show_related': False
}
# -- Options for spelling extension ----------------------------------------------
html_sidebars = {
'**': ['sidebar.html', 'searchbox.html']
}
html_show_sphinx = False
html_show_sourcelink = False
autoclass_content = 'init'
html_short_title = description
spelling_word_list_filename = [
"spelling/names.txt",
"spelling/technical.txt",
"spelling/fixes.txt",
]
spelling_ignore_wiki_words = False
# spelling_ignore_acronyms = False
spelling_ignore_python_builtins = False
spelling_ignore_importable_modules = False

View File

@@ -1,16 +1,15 @@
.. highlight:: python
.. _examples:
Examples
========
These are a few examples on how to use `fbchat`. Remember to swap out `<email>` and `<password>` for your email and password
These are a few examples on how to use ``fbchat``. Remember to swap out ``<email>`` and ``<password>`` for your email and password
Basic example
-------------
This will show basic usage of `fbchat`
This will show basic usage of ``fbchat``
.. literalinclude:: ../examples/basic_usage.py
@@ -18,7 +17,7 @@ This will show basic usage of `fbchat`
Interacting with Threads
------------------------
This will interract with the thread in every way `fbchat` supports
This will interact with the thread in every way ``fbchat`` supports
.. literalinclude:: ../examples/interract.py
@@ -31,8 +30,8 @@ This will show the different ways of fetching information about users and thread
.. literalinclude:: ../examples/fetch.py
Echobot
-------
``Echobot``
-----------
This will reply to any message with the same message
@@ -42,7 +41,7 @@ This will reply to any message with the same message
Remove Bot
----------
This will remove a user from a group if they write the message `Remove me!`
This will remove a user from a group if they write the message ``Remove me!``
.. literalinclude:: ../examples/removebot.py

View File

@@ -1,44 +1,23 @@
.. highlight:: python
.. module:: fbchat
.. _faq:
Frequently Asked Questions
==========================
FAQ
===
The new version broke my application
------------------------------------
Version X broke my installation
-------------------------------
``fbchat`` follows `Scemantic Versioning <https://semver.org/>`__ quite rigorously!
We try to provide backwards compatability where possible, but since we're not part of Facebook,
most of the things may be broken at any point in time
That means that breaking changes can *only* occur in major versions (e.g. ``v1.9.6`` -> ``v2.0.0``).
Downgrade to an earlier version of fbchat, run this command
If you find that something breaks, and you didn't update to a new major version, then it is a bug, and we would be grateful if you reported it!
In case you're stuck with an old codebase, you can downgrade to a previous version of ``fbchat``, e.g. version ``1.9.6``:
.. code-block:: sh
$ pip install fbchat==<X>
Where you replace ``<X>`` with the version you want to use
$ pip install fbchat==1.9.6
Will you be supporting creating posts/events/pages and so on?
-------------------------------------------------------------
We won't be focusing on anything else than chat-related things. This API is called `fbCHAT`, after all ;)
Submitting Issues
-----------------
If you're having trouble with some of the snippets, or you think some of the functionality is broken,
please feel free to submit an issue on `Github <https://github.com/carpedm20/fbchat>`_.
You should first login with ``logging_level`` set to ``logging.DEBUG``::
from fbchat import Client
import logging
client = Client('<email>', '<password>', logging_level=logging.DEBUG)
Then you can submit the relevant parts of this log, and detailed steps on how to reproduce
.. warning::
Always remove your credentials from any debug information you may provide us.
Preferably, use a test account, in case you miss anything
We won't be focusing on anything else than chat-related things. This library is called ``fbCHAT``, after all!

View File

@@ -1,66 +1,22 @@
.. highlight:: python
.. module:: fbchat
.. fbchat documentation master file, created by
sphinx-quickstart on Thu May 25 15:43:01 2017.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
.. See README.rst for explanation of these markers
.. This documentation's layout is heavily inspired by requests' layout: https://requests.readthedocs.io
Some documentation is also partially copied from facebook-chat-api: https://github.com/Schmavery/facebook-chat-api
.. include:: ../README.rst
:end-before: inclusion-marker-intro-end
fbchat: Facebook Chat (Messenger) for Python
============================================
With that said, let's get started!
Release v\ |version|. (:ref:`install`)
.. generated with: https://img.shields.io/badge/license-BSD-blue.svg
.. image:: /_static/license.svg
:target: https://github.com/carpedm20/fbchat/blob/master/LICENSE.txt
:alt: License: BSD
.. generated with: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6-blue.svg
.. image:: /_static/python-versions.svg
:target: https://pypi.python.org/pypi/fbchat
:alt: Supported python versions: 2.7, 3.4, 3.5 and 3.6
Facebook Chat (`Messenger <https://www.facebook.com/messages/>`_) for Python.
This project was inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`_.
**No XMPP or API key is needed**. Just use your email and password.
Currently `fbchat` support Python 2.7, 3.4, 3.5 and 3.6:
`fbchat` works by emulating the browser.
This means doing the exact same GET/POST requests and tricking Facebook into thinking it's accessing the website normally.
Therefore, this API requires the credentials of a Facebook account.
.. note::
If you're having problems, please check the :ref:`faq`, before asking questions on Github
.. warning::
We are not responsible if your account gets banned for spammy activities,
such as sending lots of messages to people you don't know, sending messages very quickly,
sending spammy looking URLs, logging in and out very quickly... Be responsible Facebook citizens.
.. note::
Facebook now has an `official API <https://developers.facebook.com/docs/messenger-platform>`_ for chat bots,
so if you're familiar with node.js, this might be what you're looking for.
If you're already familiar with the basics of how Facebook works internally, go to :ref:`examples` to see example usage of `fbchat`
.. include:: ../README.rst
:start-after: inclusion-marker-installation-start
:end-before: inclusion-marker-installation-end
Overview
--------
Documentation Overview
----------------------
.. toctree::
:maxdepth: 2
install
intro
examples
testing
api
todo
faq
api/index

View File

@@ -1,36 +0,0 @@
.. highlight:: sh
.. _install:
Installation
============
Pip Install fbchat
------------------
To install fbchat, run this command::
$ pip install fbchat
If you don't have `pip <https://pip.pypa.io>`_ installed,
`this Python installation guide <http://docs.python-guide.org/en/latest/starting/installation/>`_
can guide you through the process.
Get the Source Code
-------------------
fbchat is developed on GitHub, where the code is
`always available <https://github.com/carpedm20/fbchat>`_.
You can either clone the public repository::
$ git clone git://github.com/carpedm20/fbchat.git
Or, download a `tarball <https://github.com/carpedm20/fbchat/tarball/master>`_::
$ curl -OL https://github.com/carpedm20/fbchat/tarball/master
# optionally, zipball is also available (for Windows users).
Once you have a copy of the source, you can embed it in your own Python
package, or install it into your site-packages easily::
$ python setup.py install

View File

@@ -1,200 +1,152 @@
.. highlight:: python
.. module:: fbchat
.. _intro:
Introduction
============
`fbchat` uses your email and password to communicate with the Facebook server.
That means that you should always store your password in a seperate file, in case e.g. someone looks over your shoulder while you're writing code.
You should also make sure that the file's access control is appropriately restrictive
Welcome, this page will guide you through the basic concepts of using ``fbchat``.
The hardest, and most error prone part is logging in, and managing your login session, so that is what we will look at first.
.. _intro_logging_in:
Logging In
----------
Simply create an instance of :class:`Client`. If you have two factor authentication enabled, type the code in the terminal prompt
(If you want to supply the code in another fasion, overwrite :func:`Client.on2FACode`)::
Everything in ``fbchat`` starts with getting an instance of `Session`. Currently there are two ways of doing that, `Session.login` and `Session.from_cookies`.
from fbchat import Client
from fbchat.models import *
client = Client('<email>', '<password>')
The follow example will prompt you for you password, and use it to login::
Replace ``<email>`` and ``<password>`` with your email and password respectively
import getpass
import fbchat
session = fbchat.Session.login("<email/phone number>", getpass.getpass())
# If your account requires a two factor authentication code:
session = fbchat.Session.login(
"<your email/phone number>",
getpass.getpass(),
lambda: getpass.getpass("2FA code"),
)
.. note::
For ease of use then most of the code snippets in this document will assume you've already completed the login process
Though the second line, ``from fbchat.models import *``, is not strictly neccesary here, later code snippets will assume you've done this
However, **this is not something you should do often!** Logging in/out all the time *will* get your Facebook account locked!
If you want to change how verbose `fbchat` is, change the logging level (in :class:`Client`)
Instead, you should start by using `Session.login`, and then store the cookies with `Session.get_cookies`, so that they can be used instead the next time your application starts.
Throughout your code, if you want to check whether you are still logged in, use :func:`Client.isLoggedIn`.
An example would be to login again if you've been logged out, using :func:`Client.login`::
Usability-wise, this is also better, since you won't have to re-type your password every time you want to login.
if not client.isLoggedIn():
client.login('<email>', '<password>')
The following, quite lengthy, yet very import example, illustrates a way to do this:
When you're done using the client, and want to securely logout, use :func:`Client.logout`::
.. literalinclude:: ../examples/session_handling.py
client.logout()
Assuming you have successfully completed the above, congratulations! Using ``fbchat`` should be mostly trouble free from now on!
.. _intro_threads:
Understanding Thread Ids
------------------------
Threads
-------
At the core of any thread is its unique identifier, its ID.
A thread can refer to two things: A Messenger group chat or a single Facebook user
A thread basically just means "something I can chat with", but more precisely, it can refer to a few things:
- A Messenger group thread (`Group`)
- The conversation between you and a single Facebook user (`User`)
- The conversation between you and a Facebook Page (`Page`)
:class:`models.ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``.
These will specify whether the thread is a single user chat or a group chat.
This is required for many of `fbchat`'s functions, since Facebook differetiates between these two internally
You can get your own user ID with `Session.user.id`.
Searching for group chats and finding their ID can be done via. :func:`Client.searchForGroups`,
and searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching`
Getting the ID of a specific group thread is fairly trivial, you only need to login to `<https://www.messenger.com/>`_, click on the group you want to find the ID of, and then read the id from the address bar.
The URL will look something like this: ``https://www.messenger.com/t/1234567890``, where ``1234567890`` would be the ID of the group.
You can get your own user ID by using :any:`Client.uid`
The same method can be applied to some user accounts, though if they have set a custom URL, then you will have to use a different method.
Getting the ID of a group chat is fairly trivial otherwise, since you only need to navigate to `<https://www.facebook.com/messages/>`_,
click on the group you want to find the ID of, and then read the id from the address bar.
The URL will look something like this: ``https://www.facebook.com/messages/t/1234567890``, where ``1234567890`` would be the ID of the group.
An image to illustrate this is shown below:
An image to illustrate the process is shown below:
.. image:: /_static/find-group-id.png
:alt: An image illustrating how to find the ID of a group
The same method can be applied to some user accounts, though if they've set a custom URL, then you'll just see that URL instead
Once you have an ID, you can use it to create a `Group` or a `User` instance, which will allow you to do all sorts of things. To do this, you need a valid, logged in session::
Here's an snippet showing the usage of thread IDs and thread types, where ``<user id>`` and ``<group id>``
corresponds to the ID of a single user, and the ID of a group respectively::
group = fbchat.Group(session=session, id="<The id you found>")
# Or for user threads
user = fbchat.User(session=session, id="<The id you found>")
client.sendMessage('<message>', thread_id='<user id>', thread_type=ThreadType.USER)
client.sendMessage('<message>', thread_id='<group id>', thread_type=ThreadType.GROUP)
Just like threads, every message, poll, plan, attachment, action etc. you send or do on Facebook has a unique ID.
Some functions (e.g. :func:`Client.changeThreadColor`) don't require a thread type, so in these cases you just provide the thread ID::
Below is an example of using such a message ID to get a `Message` instance::
client.changeThreadColor(ThreadColor.BILOBA_FLOWER, thread_id='<user id>')
client.changeThreadColor(ThreadColor.MESSENGER_BLUE, thread_id='<group id>')
# Provide the thread the message was created in, and it's ID
message = fbchat.Message(thread=user, id="<The message id>")
.. _intro_message_ids:
Message IDs
-----------
Every message you send on Facebook has a unique ID, and every action you do in a thread,
like changing a nickname or adding a person, has a unique ID too.
Some of `fbchat`'s functions require these ID's, like :func:`Client.reactToMessage`,
and some of then provide this ID, like :func:`Client.sendMessage`.
This snippet shows how to send a message, and then use the returned ID to react to that message with a 😍 emoji::
message_id = client.sendMessage('message', thread_id=thread_id, thread_type=thread_type)
client.reactToMessage(message_id, MessageReaction.LOVE)
.. _intro_interacting:
Interacting with Threads
------------------------
`fbchat` provides multiple functions for interacting with threads
Most functionality works on all threads, though some things,
like adding users to and removing users from a group chat, logically only works on group chats
The simplest way of using `fbchat` is to send a message.
The following snippet will, as you've probably already figured out, send the message `test message` to your account::
message_id = client.sendMessage('test message', thread_id=client.uid, thread_type=ThreadType.USER)
You can see a full example showing all the possible thread interactions with `fbchat` by going to :ref:`examples`
.. _intro_fetching:
Fetching Information
--------------------
You can use `fbchat` to fetch basic information like user names, profile pictures, thread names and user IDs
Managing these ids yourself quickly becomes very cumbersome! Luckily, there are other, easier ways of getting `Group`/`User` instances.
You can retrieve a user's ID with :func:`Client.searchForUsers`.
The following snippet will search for users by their name, take the first (and most likely) user, and then get their user ID from the result::
You would start by creating a `Client` instance, which is basically just a helper on top of `Session`, that will allow you to do various things::
users = client.searchForUsers('<name of user>')
user = users[0]
print("User's ID: {}".format(user.uid))
print("User's name: {}".format(user.name))
print("User's profile picture url: {}".format(user.photo))
print("User's main url: {}".format(user.url))
client = fbchat.Client(session=session)
Since this uses Facebook's search functions, you don't have to specify the whole name, first names will usually be enough
Now, you could search for threads using `Client.search_for_threads`, or fetch a list of them using `Client.fetch_threads`::
You can see a full example showing all the possible ways to fetch information with `fbchat` by going to :ref:`examples`
# Fetch the 5 most likely search results
# Uses Facebook's search functions, you don't have to specify the whole name, first names will usually be enough
threads = list(client.search_for_threads("<name of the thread to search for>", limit=5))
# Fetch the 5 most recent threads in your account
threads = list(client.fetch_threads(limit=5))
Note the `list` statements; this is because the methods actually return `generators <https://wiki.python.org/moin/Generators>`__. If you don't know what that means, don't worry, it is just something you can use to make your code faster later.
The examples above will actually fetch `UserData`/`GroupData`, which are subclasses of `User`/`Group`. These model have extra properties, so you could for example print the names and ids of the fetched threads like this::
for thread in threads:
print(f"{thread.id}: {thread.name}")
Once you have a thread, you can use that to fetch the messages therein::
for message in thread.fetch_messages(limit=20):
print(message.text)
.. _intro_sessions:
Interacting with Threads
------------------------
Sessions
--------
Once you have a `User`/`Group` instance, you can do things on them as described in `ThreadABC`, since they are subclasses of that.
`fbchat` provides functions to retrieve and set the session cookies.
This will enable you to store the session cookies in a seperate file, so that you don't have to login each time you start your script.
Use :func:`Client.getSession` to retrieve the cookies::
Some functionality, like adding users to a `Group`, or blocking a `User`, logically only works the relevant threads, so see the full API documentation for that.
session_cookies = client.getSession()
With that out of the way, let's see some examples!
Then you can use :func:`Client.setSession`::
The simplest way of interracting with a thread is by sending a message::
client.setSession(session_cookies)
# Send a message to the user
message = user.send_text("test message")
Or you can set the ``session_cookies`` on your initial login.
(If the session cookies are invalid, your email and password will be used to login instead)::
There are many types of messages you can send, see the full API documentation for more.
client = Client('<email>', '<password>', session_cookies=session_cookies)
Notice how we held on to the sent message? The return type i a `Message` instance, so you can interract with it afterwards::
.. warning::
You session cookies can be just as valueable as you password, so store them with equal care
# React to the message with the 😍 emoji
message.react("😍")
Besides sending messages, you can also interract with threads in other ways. An example is to change the thread color::
# Will change the thread color to the default blue
thread.set_color("#0084ff")
.. _intro_events:
Listening & Events
------------------
To use the listening functions `fbchat` offers (like :func:`Client.listen`),
you have to define what should be executed when certain events happen.
By default, (most) events will just be a `logging.info` statement,
meaning it will simply print information to the console when an event happens
Now, we are finally at the point we have all been waiting for: Creating an automatic Facebook bot!
.. note::
You can identify the event methods by their `on` prefix, e.g. `onMessage`
To get started, you create the functions you want to call on certain events::
The event actions can be changed by subclassing the :class:`Client`, and then overwriting the event methods::
def my_function(event: fbchat.MessageEvent):
print(f"Message from {event.author.id}: {event.message.text}")
class CustomClient(Client):
def onMessage(self, mid, author_id, message, thread_id, thread_type, ts, metadata, msg, **kwargs):
# Do something with the message here
pass
Then you create a `fbchat.Listener` object::
client = CustomClient('<email>', '<password>')
listener = fbchat.Listener(session=session, chat_on=False, foreground=False)
**Notice:** The following snippet is as equally valid as the previous one::
Which you can then use to receive events, and send them to your functions::
class CustomClient(Client):
def onMessage(self, message, author_id, thread_id, thread_type, **kwargs):
# Do something with the message here
pass
for event in listener.listen():
if isinstance(event, fbchat.MessageEvent):
my_function(event)
client = CustomClient('<email>', '<password>')
The change was in the parameters that our `onMessage` method took: ``message`` and ``author_id`` got swapped,
and ``mid``, ``ts``, ``metadata`` and ``msg`` got removed, but the function still works, since we included ``**kwargs``
.. note::
Therefore, for both backwards and forwards compatability,
the API actually requires that you include ``**kwargs`` as your final argument.
View the :ref:`examples` to see some more examples illustrating the event system
View the :ref:`examples` to see some more examples illustrating the event system.

View File

@@ -5,21 +5,20 @@ pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=python -msphinx
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
set SPHINXPROJ=fbchat
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The Sphinx module was not found. Make sure you have Sphinx installed,
echo.then set the SPHINXBUILD environment variable to point to the full
echo.path of the 'sphinx-build' executable. Alternatively you may add the
echo.Sphinx directory to PATH.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/

3
docs/spelling/fixes.txt Normal file
View File

@@ -0,0 +1,3 @@
premade
todo
emoji

3
docs/spelling/names.txt Normal file
View File

@@ -0,0 +1,3 @@
Facebook
GraphQL
GitHub

View File

@@ -0,0 +1,17 @@
iterables
iterable
mimetype
timestamp
metadata
spam
spammy
admin
admins
unsend
unsends
unmute
spritemap
online
inbox
subclassing
codebase

View File

@@ -1,29 +0,0 @@
.. highlight:: sh
.. module:: fbchat
.. _testing:
Testing
=======
To use the tests, copy ``tests/data.json`` to ``tests/my_data.json`` or type the information manually in the terminal prompts.
- email: Your (or a test user's) email / phone number
- password: Your (or a test user's) password
- group_thread_id: A test group that will be used to test group functionality
- user_thread_id: A person that will be used to test kick/add functionality (This user should be in the group)
Please remember to test all supported python versions.
If you've made any changes to the 2FA functionality, test it with a 2FA enabled account.
If you only want to execute specific tests, pass the function names in the commandline (not including the `test_` prefix). Example::
$ python tests.py sendMessage sessions sendEmoji
.. warning::
Do not execute the full set of tests in too quick succession. This can get your account temporarily blocked for spam!
(You should execute the script at max about 10 times a day)
.. automodule:: tests
:members: TestFbchat
:undoc-members: TestFbchat

View File

@@ -1,24 +0,0 @@
.. highlight:: python
.. module:: fbchat
.. _todo:
Todo
====
This page will be periodically updated to show missing features and documentation
Missing Functionality
---------------------
- Implement Client.searchForMessage
- This will use the graphql request API
- Implement chatting with pages properly
- Write better FAQ
- Explain usage of graphql
Documentation
-------------
.. todolist::

View File

@@ -1,12 +1,12 @@
# -*- coding: UTF-8 -*-
import fbchat
from fbchat import Client
from fbchat.models import *
# Log the user in
session = fbchat.Session.login("<email>", "<password>")
client = Client('<email>', '<password>')
print("Own id: {}".format(session.user.id))
print('Own id: {}'.format(client.uid))
# Send a message to yourself
session.user.send_text("Hi me!")
client.sendMessage('Hi me!', thread_id=client.uid, thread_type=ThreadType.USER)
client.logout()
# Log the user out
session.logout()

View File

@@ -1,18 +1,11 @@
# -*- coding: UTF-8 -*-
import fbchat
from fbchat import log, Client
# Subclass fbchat.Client and override required methods
class EchoBot(Client):
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))
session = fbchat.Session.login("<email>", "<password>")
listener = fbchat.Listener(session=session, chat_on=False, foreground=False)
for event in listener.listen():
if isinstance(event, fbchat.MessageEvent):
print(f"{event.message.text} from {event.author.id} in {event.thread.id}")
# 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("<email>", "<password>")
client.listen()
if event.author.id != session.user.id:
event.thread.send_text(event.message.text)

View File

@@ -1,46 +1,50 @@
# -*- coding: UTF-8 -*-
import fbchat
from fbchat import Client
from fbchat.models import *
session = fbchat.Session.login("<email>", "<password>")
client = Client('<email>', '<password>')
client = fbchat.Client(session)
# Fetches a list of all users you're currently chatting with, as `User` objects
users = client.fetchAllUsers()
users = client.fetch_all_users()
print("users' IDs: {}".format(user.uid for user in users))
print("users' names: {}".format(user.name for user in users))
print("users' IDs: {}".format([user.id for user in users]))
print("users' names: {}".format([user.name for user in users]))
# If we have a user id, we can use `fetchUserInfo` to fetch a `User` object
user = client.fetchUserInfo('<user id>')['<user id>']
# If we have a user id, we can use `fetch_user_info` to fetch a `User` object
user = client.fetch_user_info("<user id>")["<user id>"]
# We can also query both mutiple users together, which returns list of `User` objects
users = client.fetchUserInfo('<1st user id>', '<2nd user id>', '<3rd user id>')
users = client.fetch_user_info("<1st user id>", "<2nd user id>", "<3rd user id>")
print("user's name: {}".format(user.name))
print("users' names: {}".format(users[k].name for k in users))
print("users' names: {}".format([users[k].name for k in users]))
# `searchForUsers` searches for the user and gives us a list of the results,
# `search_for_users` searches for the user and gives us a list of the results,
# and then we just take the first one, aka. the most likely one:
user = client.searchForUsers('<name of user>')[0]
user = client.search_for_users("<name of user>")[0]
print('user ID: {}'.format(user.uid))
print("user ID: {}".format(user.id))
print("user's name: {}".format(user.name))
print("user's photo: {}".format(user.photo))
print("Is user client's friend: {}".format(user.is_friend))
# Fetches a list of the 20 top threads you're currently chatting with
threads = client.fetchThreadList()
threads = client.fetch_thread_list()
# Fetches the next 10 threads
threads += client.fetchThreadList(offset=20, limit=10)
threads += client.fetch_thread_list(offset=20, limit=10)
print("Threads: {}".format(threads))
# If we have a thread id, we can use `fetch_thread_info` to fetch a `Thread` object
thread = client.fetch_thread_info("<thread id>")["<thread id>"]
print("thread's name: {}".format(thread.name))
# Gets the last 10 messages sent to the thread
messages = client.fetchThreadMessages(thread_id='<thread id>', limit=10)
messages = thread.fetch_messages(limit=10)
# Since the message come in reversed order, reverse them
messages.reverse()
@@ -49,16 +53,17 @@ for message in messages:
print(message.text)
# If we have a thread id, we can use `fetchThreadInfo` to fetch a `Thread` object
thread = client.fetchThreadInfo('<thread id>')['<thread id>']
# `search_for_threads` searches works like `search_for_users`, but gives us a list of threads instead
thread = client.search_for_threads("<name of thread>")[0]
print("thread's name: {}".format(thread.name))
print("thread's type: {}".format(thread.type))
# `searchForThreads` searches works like `searchForUsers`, but gives us a list of threads instead
thread = client.searchForThreads('<name of thread>')[0]
print("thread's name: {}".format(thread.name))
print("thread's type: {}".format(thread.type))
# Here should be an example of `getUnread`
# Print image url for up to 20 last images from thread.
images = list(thread.fetch_images(limit=20))
for image in images:
if isinstance(image, fbchat.ImageAttachment):
url = c.fetch_image_url(image.id)
print(url)

View File

@@ -1,55 +1,66 @@
# -*- coding: UTF-8 -*-
import fbchat
import requests
from fbchat import Client
from fbchat.models import *
session = fbchat.Session.login("<email>", "<password>")
client = Client("<email>", "<password>")
client = fbchat.Client(session)
thread_id = '1234567890'
thread_type = ThreadType.GROUP
thread = session.user
# thread = fbchat.User(session=session, id="0987654321")
# thread = fbchat.Group(session=session, id="1234567890")
# Will send a message to the thread
client.sendMessage('<message>', thread_id=thread_id, thread_type=thread_type)
thread.send_text("<message>")
# Will send the default `like` emoji
client.sendEmoji(emoji=None, size=EmojiSize.LARGE, thread_id=thread_id, thread_type=thread_type)
thread.send_sticker(fbchat.EmojiSize.LARGE.value)
# Will send the emoji `👍`
client.sendEmoji(emoji='👍', size=EmojiSize.LARGE, thread_id=thread_id, thread_type=thread_type)
thread.send_emoji("👍", size=fbchat.EmojiSize.LARGE)
# Will send the sticker with ID `767334476626295`
thread.send_sticker("767334476626295")
# Will send a message with a mention
thread.send_text(
text="This is a @mention",
mentions=[fbchat.Mention(thread.id, offset=10, length=8)],
)
# Will send the image located at `<image path>`
client.sendLocalImage('<image path>', message='This is a local image', thread_id=thread_id, thread_type=thread_type)
with open("<image path>", "rb") as f:
files = client.upload([("image_name.png", f, "image/png")])
thread.send_text(text="This is a local image", files=files)
# Will download the image at the url `<image url>`, and then send it
client.sendRemoteImage('<image url>', message='This is a remote image', thread_id=thread_id, thread_type=thread_type)
# Will download the image at the URL `<image url>`, and then send it
r = requests.get("<image url>")
files = client.upload([("image_name.png", r.content, "image/png")])
thread.send_files(files) # Alternative to .send_text
# Only do these actions if the thread is a group
if thread_type == ThreadType.GROUP:
# Will remove the user with ID `<user id>` from the thread
client.removeUserFromGroup('<user id>', thread_id=thread_id)
# Will add the user with ID `<user id>` to the thread
client.addUsersToGroup('<user id>', thread_id=thread_id)
# Will add the users with IDs `<1st user id>`, `<2nd user id>` and `<3th user id>` to the thread
client.addUsersToGroup(['<1st user id>', '<2nd user id>', '<3rd user id>'], thread_id=thread_id)
if isinstance(thread, fbchat.Group):
# Will remove the user with ID `<user id>` from the group
thread.remove_participant("<user id>")
# Will add the users with IDs `<1st user id>`, `<2nd user id>` and `<3th user id>` to the group
thread.add_participants(["<1st user id>", "<2nd user id>", "<3rd user id>"])
# Will change the title of the group to `<title>`
thread.set_title("<title>")
# Will change the nickname of the user `<user_id>` to `<new nickname>`
client.changeNickname('<new nickname>', '<user id>', thread_id=thread_id, thread_type=thread_type)
# Will change the nickname of the user `<user id>` to `<new nickname>`
thread.set_nickname(fbchat.User(session=session, id="<user id>"), "<new nickname>")
# Will change the title of the thread to `<title>`
client.changeThreadTitle('<title>', thread_id=thread_id, thread_type=thread_type)
# Will set the typing status of the thread
thread.start_typing()
# Will set the typing status of the thread to `TYPING`
client.setTypingStatus(TypingStatus.TYPING, thread_id=thread_id, thread_type=thread_type)
# Will change the thread color to `MESSENGER_BLUE`
client.changeThreadColor(ThreadColor.MESSENGER_BLUE, thread_id=thread_id)
# Will change the thread color to #0084ff
thread.set_color("#0084ff")
# Will change the thread emoji to `👍`
client.changeThreadEmoji('👍', thread_id=thread_id)
thread.set_emoji("👍")
message = fbchat.Message(session=session, id="<message id>")
# Will react to a message with a 😍 emoji
client.reactToMessage('<message id>', MessageReaction.LOVE)
message.react("😍")

View File

@@ -1,54 +1,92 @@
# -*- coding: UTF-8 -*-
from fbchat import log, Client
from fbchat.models import *
# This example uses the `blinker` library to dispatch events. See echobot.py for how
# this could be done differenly. The decision is entirely up to you!
import fbchat
import blinker
# Change this to your group id
old_thread_id = '1234567890'
old_thread_id = "1234567890"
# Change these to match your liking
old_color = ThreadColor.MESSENGER_BLUE
old_emoji = '👍'
old_title = 'Old group chat name'
old_color = "#0084ff"
old_emoji = "👍"
old_title = "Old group chat name"
old_nicknames = {
'12345678901': "User nr. 1's nickname",
'12345678902': "User nr. 2's nickname",
'12345678903': "User nr. 3's nickname",
'12345678904': "User nr. 4's nickname"
"12345678901": "User nr. 1's nickname",
"12345678902": "User nr. 2's nickname",
"12345678903": "User nr. 3's nickname",
"12345678904": "User nr. 4's nickname",
}
class KeepBot(Client):
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)
# Create a blinker signal
events = blinker.Signal()
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))
self.changeThreadEmoji(old_emoji, thread_id=thread_id)
# Register various event handlers on the signal
@events.connect_via(fbchat.ColorSet)
def on_color_set(sender, event: fbchat.ColorSet):
if old_thread_id != event.thread.id:
return
if old_color != event.color:
print(f"{event.author.id} changed the thread color. It will be changed back")
event.thread.set_color(old_color)
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, **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)
@events.connect_via(fbchat.EmojiSet)
def on_emoji_set(sender, event: fbchat.EmojiSet):
if old_thread_id != event.thread.id:
return
if old_emoji != event.emoji:
print(f"{event.author.id} changed the thread emoji. It will be changed back")
event.thread.set_emoji(old_emoji)
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.changeThreadTitle(old_title, thread_id=thread_id, thread_type=thread_type)
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))
self.changeNickname(old_nicknames[changed_for], changed_for, thread_id=thread_id, thread_type=thread_type)
@events.connect_via(fbchat.TitleSet)
def on_title_set(sender, event: fbchat.TitleSet):
if old_thread_id != event.thread.id:
return
if old_title != event.title:
print(f"{event.author.id} changed the thread title. It will be changed back")
event.thread.set_title(old_title)
client = KeepBot("<email>", "<password>")
client.listen()
@events.connect_via(fbchat.NicknameSet)
def on_nickname_set(sender, event: fbchat.NicknameSet):
if old_thread_id != event.thread.id:
return
old_nickname = old_nicknames.get(event.subject.id)
if old_nickname != event.nickname:
print(
f"{event.author.id} changed {event.subject.id}'s' nickname."
" It will be changed back"
)
event.thread.set_nickname(event.subject.id, old_nickname)
@events.connect_via(fbchat.PeopleAdded)
def on_people_added(sender, event: fbchat.PeopleAdded):
if old_thread_id != event.thread.id:
return
if event.author.id != session.user.id:
print(f"{', '.join(x.id for x in event.added)} got added. They will be removed")
for added in event.added:
event.thread.remove_participant(added.id)
@events.connect_via(fbchat.PersonRemoved)
def on_person_removed(sender, event: fbchat.PersonRemoved):
if old_thread_id != event.thread.id:
return
# No point in trying to add ourself
if event.removed.id == session.user.id:
return
if event.author.id != session.user.id:
print(f"{event.removed.id} got removed. They will be re-added")
event.thread.add_participants([removed.id])
# Login, and start listening for events
session = fbchat.Session.login("<email>", "<password>")
listener = fbchat.Listener(session=session, chat_on=False, foreground=False)
for event in listener.listen():
# Dispatch the event to the subscribed handlers
events.send(type(event), event=event)

View File

@@ -1,17 +1,17 @@
# -*- coding: UTF-8 -*-
import fbchat
from fbchat import log, Client
from fbchat.models import *
class RemoveBot(Client):
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(author_id, thread_id=thread_id)
else:
# Sends the data to the inherited onMessage, so that we can still see when a message is recieved
super(type(self), self).onMessage(author_id=author_id, message=message, thread_id=thread_id, thread_type=thread_type, **kwargs)
def on_message(event):
# We can only kick people from group chats, so no need to try if it's a user chat
if not isinstance(event.thread, fbchat.Group):
return
if message.text == "Remove me!":
print(f"{event.author.id} will be removed from {event.thread.id}")
event.thread.remove_participant(event.author.id)
client = RemoveBot("<email>", "<password>")
client.listen()
session = fbchat.Session.login("<email>", "<password>")
listener = fbchat.Listener(session=session, chat_on=False, foreground=False)
for event in listener.listen():
if isinstance(event, fbchat.MessageEvent):
on_message(event)

View File

@@ -0,0 +1,42 @@
# TODO: Consider adding Session.from_file and Session.to_file,
# which would make this example a lot easier!
import atexit
import json
import getpass
import fbchat
def load_cookies(filename):
try:
# Load cookies from file
with open(filename) as f:
return json.load(f)
except FileNotFoundError:
return # No cookies yet
def save_cookies(filename, cookies):
with open(filename, "w") as f:
json.dump(f, cookies)
def load_session(cookies):
if not cookies:
return
try:
return fbchat.Session.from_cookies(cookies)
except fbchat.FacebookError:
return # Failed loading from cookies
cookies = load_cookies("session.json")
session = load_session(cookies)
if not session:
# Session could not be loaded, login instead!
session = fbchat.Session.login("<email>", getpass.getpass())
# Save session cookies to file when the program exits
atexit.register(lambda: save_cookies("session.json", session.get_cookies()))
# Do stuff with session here

View File

@@ -1,29 +1,126 @@
# -*- coding: UTF-8 -*-
"""Facebook Messenger for Python.
from __future__ import unicode_literals
from datetime import datetime
from .client import *
Copyright:
(c) 2015 - 2018 by Taehoon Kim
(c) 2018 - 2020 by Mads Marquart
"""
fbchat
~~~~~~
Facebook Chat (Messenger) for Python
:copyright: (c) 2015 by Taehoon Kim.
:license: BSD, see LICENSE for more details.
License:
BSD 3-Clause, see LICENSE for more details.
"""
import logging as _logging
__copyright__ = 'Copyright 2015 - {} by Taehoon Kim'.format(datetime.now().year)
__version__ = '1.0.9'
__license__ = 'BSD'
__author__ = 'Taehoon Kim; Moreels Pieter-Jan; Mads Marquart'
__email__ = 'carpedm20@gmail.com'
__source__ = 'https://github.com/carpedm20/fbchat/'
__description__ = 'Facebook Chat (Messenger) for Python'
# Set default logging handler to avoid "No handler found" warnings.
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
__all__ = [
'Client',
]
# The order of these is somewhat significant, e.g. User has to be imported after Thread!
from . import _common, _util
from ._exception import (
FacebookError,
HTTPError,
ParseError,
ExternalError,
GraphQLError,
InvalidParameters,
NotLoggedIn,
PleaseRefresh,
)
from ._session import Session
from ._threads import (
ThreadABC,
Thread,
User,
UserData,
Group,
GroupData,
Page,
PageData,
)
# Models
from ._models import (
Image,
ThreadLocation,
ActiveStatus,
Attachment,
UnsentMessage,
ShareAttachment,
LocationAttachment,
LiveLocationAttachment,
Sticker,
FileAttachment,
AudioAttachment,
ImageAttachment,
VideoAttachment,
Poll,
PollOption,
GuestStatus,
Plan,
PlanData,
QuickReply,
QuickReplyText,
QuickReplyLocation,
QuickReplyPhoneNumber,
QuickReplyEmail,
EmojiSize,
Mention,
Message,
MessageData,
)
# Events
from ._events import (
# _common
Event,
UnknownEvent,
ThreadEvent,
# _client_payload
ReactionEvent,
UserStatusEvent,
LiveLocationEvent,
UnsendEvent,
MessageReplyEvent,
# _delta_class
PeopleAdded,
PersonRemoved,
TitleSet,
UnfetchedThreadEvent,
MessagesDelivered,
ThreadsRead,
MessageEvent,
ThreadFolder,
# _delta_type
ColorSet,
EmojiSet,
NicknameSet,
AdminsAdded,
AdminsRemoved,
ApprovalModeSet,
CallStarted,
CallEnded,
CallJoined,
PollCreated,
PollVoted,
PlanCreated,
PlanEnded,
PlanEdited,
PlanDeleted,
PlanResponded,
# __init__
Typing,
FriendRequest,
Presence,
)
from ._listen import Listener
from ._client import Client
__version__ = "2.0.0a1"
__all__ = ("Session", "Listener", "Client")
from . import _fix_module_metadata
_fix_module_metadata.fixup_module_metadata(globals())
del _fix_module_metadata

648
fbchat/_client.py Normal file
View File

@@ -0,0 +1,648 @@
import attr
import datetime
from ._common import log, attrs_default
from . import _exception, _util, _graphql, _session, _threads, _models
from typing import Sequence, Iterable, Tuple, Optional, Set, BinaryIO
@attrs_default
class Client:
"""A client for Facebook Messenger.
This contains methods that are generally needed to interact with Facebook.
Example:
Create a new client instance.
>>> client = fbchat.Client(session=session)
"""
#: The session to use when making requests.
session = attr.ib(type=_session.Session)
def fetch_users(self) -> Sequence[_threads.UserData]:
"""Fetch users the client is currently chatting with.
This is very close to your friend list, with the follow differences:
It differs by including users that you're not friends with, but have chatted
with before, and by including accounts that are "Messenger Only".
But does not include deactivated, deleted or memorialized users (logically,
since you can't chat with those).
The order these are returned is arbitary.
Example:
Get the name of an arbitary user that you're currently chatting with.
>>> users = client.fetch_users()
>>> users[0].name
"A user"
"""
data = {"viewer": self.session.user.id}
j = self.session._payload_post("/chat/user_info_all", data)
users = []
for data in j.values():
if data["type"] not in ["user", "friend"] or data["id"] in ["0", 0]:
log.warning("Invalid user data %s", data)
continue # Skip invalid users
users.append(_threads.UserData._from_all_fetch(self.session, data))
return users
def search_for_users(self, name: str, limit: int) -> Iterable[_threads.UserData]:
"""Find and get users by their name.
The returned users are ordered by relevance.
Args:
name: Name of the user
limit: The max. amount of users to fetch
Example:
Get the full name of the first found user.
>>> (user,) = client.search_for_users("user", limit=1)
>>> user.name
"A user"
"""
params = {"search": name, "limit": limit}
(j,) = self.session._graphql_requests(
_graphql.from_query(_graphql.SEARCH_USER, params)
)
return (
_threads.UserData._from_graphql(self.session, node)
for node in j[name]["users"]["nodes"]
)
def search_for_pages(self, name: str, limit: int) -> Iterable[_threads.PageData]:
"""Find and get pages by their name.
The returned pages are ordered by relevance.
Args:
name: Name of the page
limit: The max. amount of pages to fetch
Example:
Get the full name of the first found page.
>>> (page,) = client.search_for_pages("page", limit=1)
>>> page.name
"A page"
"""
params = {"search": name, "limit": limit}
(j,) = self.session._graphql_requests(
_graphql.from_query(_graphql.SEARCH_PAGE, params)
)
return (
_threads.PageData._from_graphql(self.session, node)
for node in j[name]["pages"]["nodes"]
)
def search_for_groups(self, name: str, limit: int) -> Iterable[_threads.GroupData]:
"""Find and get group threads by their name.
The returned groups are ordered by relevance.
Args:
name: Name of the group thread
limit: The max. amount of groups to fetch
Example:
Get the full name of the first found group.
>>> (group,) = client.search_for_groups("group", limit=1)
>>> group.name
"A group"
"""
params = {"search": name, "limit": limit}
(j,) = self.session._graphql_requests(
_graphql.from_query(_graphql.SEARCH_GROUP, params)
)
return (
_threads.GroupData._from_graphql(self.session, node)
for node in j["viewer"]["groups"]["nodes"]
)
def search_for_threads(self, name: str, limit: int) -> Iterable[_threads.ThreadABC]:
"""Find and get threads by their name.
The returned threads are ordered by relevance.
Args:
name: Name of the thread
limit: The max. amount of threads to fetch
Example:
Search for a user, and get the full name of the first found result.
>>> (user,) = client.search_for_threads("user", limit=1)
>>> assert isinstance(user, fbchat.User)
>>> user.name
"A user"
"""
params = {"search": name, "limit": limit}
(j,) = self.session._graphql_requests(
_graphql.from_query(_graphql.SEARCH_THREAD, params)
)
for node in j[name]["threads"]["nodes"]:
if node["__typename"] == "User":
yield _threads.UserData._from_graphql(self.session, node)
elif node["__typename"] == "MessageThread":
# MessageThread => Group thread
yield _threads.GroupData._from_graphql(self.session, node)
elif node["__typename"] == "Page":
yield _threads.PageData._from_graphql(self.session, node)
elif node["__typename"] == "Group":
# We don't handle Facebook "Groups"
pass
else:
log.warning(
"Unknown type {} in {}".format(repr(node["__typename"]), node)
)
def _search_messages(self, query, offset, limit):
data = {"query": query, "offset": offset, "limit": limit}
j = self.session._payload_post("/ajax/mercury/search_snippets.php?dpr=1", data)
total_snippets = j["search_snippets"][query]
rtn = []
for node in j["graphql_payload"]["message_threads"]:
type_ = node["thread_type"]
if type_ == "GROUP":
thread = _threads.Group(
session=self.session, id=node["thread_key"]["thread_fbid"]
)
elif type_ == "ONE_TO_ONE":
thread = _threads.Thread(
session=self.session, id=node["thread_key"]["other_user_id"]
)
# if True: # TODO: This check!
# thread = _threads.UserData._from_graphql(self.session, node)
# else:
# thread = _threads.PageData._from_graphql(self.session, node)
else:
thread = None
log.warning("Unknown thread type %s, data: %s", type_, node)
if thread:
rtn.append((thread, total_snippets[thread.id]["num_total_snippets"]))
else:
rtn.append((None, 0))
return rtn
def search_messages(
self, query: str, limit: Optional[int]
) -> Iterable[Tuple[_threads.ThreadABC, int]]:
"""Search for messages in all threads.
Intended to be used alongside `ThreadABC.search_messages`.
Warning! If someone send a message to a thread that matches the query, while
we're searching, some snippets will get returned twice, and some will be lost.
This is fundamentally unfixable, it's just how the endpoint is implemented.
Args:
query: Text to search for
limit: Max. number of items to retrieve. If ``None``, all will be retrieved
Example:
Search for messages, and print the amount of snippets in each thread.
>>> for thread, count in client.search_messages("abc", limit=3):
... print(f"{thread.id} matched the search {count} time(s)")
...
1234 matched the search 2 time(s)
2345 matched the search 1 time(s)
3456 matched the search 100 time(s)
Return:
Iterable with tuples of threads, and the total amount of matches.
"""
offset = 0
# The max limit is measured empirically to ~500, safe default chosen below
for limit in _util.get_limits(limit, max_limit=100):
data = self._search_messages(query, offset, limit)
for thread, total_snippets in data:
if thread:
yield (thread, total_snippets)
if len(data) < limit:
return # No more data to fetch
offset += limit
def _fetch_info(self, *ids):
data = {"ids[{}]".format(i): _id for i, _id in enumerate(ids)}
j = self.session._payload_post("/chat/user_info/", data)
if j.get("profiles") is None:
raise _exception.ParseError("No users/pages returned", data=j)
entries = {}
for _id in j["profiles"]:
k = j["profiles"][_id]
if k["type"] in ["user", "friend"]:
entries[_id] = {
"id": _id,
"url": k.get("uri"),
"first_name": k.get("firstName"),
"is_viewer_friend": k.get("is_friend"),
"gender": k.get("gender"),
"profile_picture": {"uri": k.get("thumbSrc")},
"name": k.get("name"),
}
elif k["type"] == "page":
entries[_id] = {
"id": _id,
"url": k.get("uri"),
"profile_picture": {"uri": k.get("thumbSrc")},
"name": k.get("name"),
}
else:
raise _exception.ParseError("Unknown thread type", data=k)
log.debug(entries)
return entries
def fetch_thread_info(self, ids: Iterable[str]) -> Iterable[_threads.ThreadABC]:
"""Fetch threads' info from IDs, unordered.
Warning:
Sends two requests if users or pages are present, to fetch all available info!
Args:
ids: Thread ids to query
Example:
Get data about the user with id "4".
>>> (user,) = client.fetch_thread_info(["4"])
>>> user.name
"Mark Zuckerberg"
"""
ids = list(ids)
queries = []
for thread_id in ids:
params = {
"id": thread_id,
"message_limit": 0,
"load_messages": False,
"load_read_receipts": False,
"before": None,
}
queries.append(_graphql.from_doc_id("2147762685294928", params))
j = self.session._graphql_requests(*queries)
for i, entry in enumerate(j):
if entry.get("message_thread") is None:
# If you don't have an existing thread with this person, attempt to retrieve user data anyways
j[i]["message_thread"] = {
"thread_key": {"other_user_id": ids[i]},
"thread_type": "ONE_TO_ONE",
}
pages_and_user_ids = [
k["message_thread"]["thread_key"]["other_user_id"]
for k in j
if k["message_thread"].get("thread_type") == "ONE_TO_ONE"
]
pages_and_users = {}
if len(pages_and_user_ids) != 0:
pages_and_users = self._fetch_info(*pages_and_user_ids)
for i, entry in enumerate(j):
entry = entry["message_thread"]
if entry.get("thread_type") == "GROUP":
_id = entry["thread_key"]["thread_fbid"]
yield _threads.GroupData._from_graphql(self.session, entry)
elif entry.get("thread_type") == "ONE_TO_ONE":
_id = entry["thread_key"]["other_user_id"]
if pages_and_users.get(_id) is None:
raise _exception.ParseError(
"Could not fetch thread {}".format(_id), data=pages_and_users
)
entry.update(pages_and_users[_id])
if "first_name" in entry:
yield _threads.UserData._from_graphql(self.session, entry)
else:
yield _threads.PageData._from_graphql(self.session, entry)
else:
raise _exception.ParseError("Unknown thread type", data=entry)
def _fetch_threads(self, limit, before, folders):
params = {
"limit": limit,
"tags": folders,
"before": _util.datetime_to_millis(before) if before else None,
"includeDeliveryReceipts": True,
"includeSeqID": False,
}
(j,) = self.session._graphql_requests(
_graphql.from_doc_id("1349387578499440", params)
)
rtn = []
for node in j["viewer"]["message_threads"]["nodes"]:
_type = node.get("thread_type")
if _type == "GROUP":
rtn.append(_threads.GroupData._from_graphql(self.session, node))
elif _type == "ONE_TO_ONE":
rtn.append(_threads.UserData._from_thread_fetch(self.session, node))
else:
rtn.append(None)
log.warning("Unknown thread type: %s, data: %s", _type, node)
return rtn
def fetch_threads(
self,
limit: Optional[int],
location: _models.ThreadLocation = _models.ThreadLocation.INBOX,
) -> Iterable[_threads.ThreadABC]:
"""Fetch the client's thread list.
The returned threads are ordered by last active first.
Args:
limit: Max. number of threads to retrieve. If ``None``, all threads will be
retrieved.
location: INBOX, PENDING, ARCHIVED or OTHER
Example:
Fetch the last three threads that the user chatted with.
>>> for thread in client.fetch_threads(limit=3):
... print(f"{thread.id}: {thread.name}")
...
1234: A user
2345: A group
3456: A page
"""
# This is measured empirically as 837, safe default chosen below
MAX_BATCH_LIMIT = 100
# TODO: Clean this up after implementing support for more threads types
seen_ids = set() # type: Set[str]
before = None
for limit in _util.get_limits(limit, MAX_BATCH_LIMIT):
threads = self._fetch_threads(limit, before, [location.value])
before = None
for thread in threads:
# Don't return seen and unknown threads
if thread and thread.id not in seen_ids:
seen_ids.add(thread.id)
# TODO: Ensure type-wise that .last_active is available
before = thread.last_active
yield thread
if len(threads) < MAX_BATCH_LIMIT:
return # No more data to fetch
# We check this here in case _fetch_threads only returned `None` threads
if not before:
raise ValueError("Too many unknown threads.")
def fetch_unread(self) -> Sequence[_threads.ThreadABC]:
"""Fetch unread threads.
Warning:
This is not finished, and the API may change at any point!
"""
at = datetime.datetime.utcnow()
form = {
"folders[0]": "inbox",
"client": "mercury",
"last_action_timestamp": _util.datetime_to_millis(at),
# 'last_action_timestamp': 0
}
j = self.session._payload_post("/ajax/mercury/unread_threads.php", form)
result = j["unread_thread_fbids"][0]
# TODO: Parse Pages?
return [
_threads.Group(session=self.session, id=id_)
for id_ in result["thread_fbids"]
] + [
_threads.User(session=self.session, id=id_)
for id_ in result["other_user_fbids"]
]
def fetch_unseen(self) -> Sequence[_threads.ThreadABC]:
"""Fetch unseen / new threads.
Warning:
This is not finished, and the API may change at any point!
"""
j = self.session._payload_post("/mercury/unseen_thread_ids/", {})
result = j["unseen_thread_fbids"][0]
# TODO: Parse Pages?
return [
_threads.Group(session=self.session, id=id_)
for id_ in result["thread_fbids"]
] + [
_threads.User(session=self.session, id=id_)
for id_ in result["other_user_fbids"]
]
def fetch_image_url(self, image_id: str) -> str:
"""Fetch URL to download the original image from an image attachment ID.
Args:
image_id: The image you want to fetch
Example:
>>> client.fetch_image_url("1234")
"https://scontent-arn1-1.xx.fbcdn.net/v/t1.123-4/1_23_45_n.png?..."
Returns:
An URL where you can download the original image
"""
image_id = str(image_id)
data = {"photo_id": str(image_id)}
j = self.session._post("/mercury/attachments/photo/", data)
_exception.handle_payload_error(j)
if "jsmods" not in j:
raise _exception.ParseError("No jsmods when fetching image URL", data=j)
require = _util.get_jsmods_require(j["jsmods"]["require"])
if "ServerRedirect.redirectPageTo" not in require:
raise _exception.ParseError("Could not fetch image URL", data=j)
# Return the first argument
return require["ServerRedirect.redirectPageTo"][0]
def _get_private_data(self):
(j,) = self.session._graphql_requests(
_graphql.from_doc_id("1868889766468115", {})
)
return j["viewer"]
def get_phone_numbers(self) -> Sequence[str]:
"""Fetch the user's phone numbers."""
data = self._get_private_data()
return [
j["phone_number"]["universal_number"] for j in data["user"]["all_phones"]
]
def get_emails(self) -> Sequence[str]:
"""Fetch the user's emails."""
data = self._get_private_data()
return [j["display_email"] for j in data["all_emails"]]
def upload(
self, files: Iterable[Tuple[str, BinaryIO, str]], voice_clip: bool = False
) -> Sequence[Tuple[str, str]]:
"""Upload files to Facebook.
`files` should be a list of files that requests can upload, see
`requests.request <https://docs.python-requests.org/en/master/api/#requests.request>`_.
Example:
>>> with open("file.txt", "rb") as f:
... (file,) = client.upload([("file.txt", f, "text/plain")])
...
>>> file
("1234", "text/plain")
Return:
Tuples with a file's ID and mimetype.
This result can be passed straight on to `ThreadABC.send_files`, or used in
`Group.set_image`.
"""
file_dict = {"upload_{}".format(i): f for i, f in enumerate(files)}
data = {"voice_clip": voice_clip}
j = self.session._payload_post(
"https://upload.facebook.com/ajax/mercury/upload.php", data, files=file_dict
)
if len(j["metadata"]) != len(file_dict):
raise _exception.ParseError("Some files could not be uploaded", data=j)
return [
(str(item[_util.mimetype_to_key(item["filetype"])]), item["filetype"])
for item in j["metadata"]
]
def mark_as_delivered(self, message: _models.Message):
"""Mark a message as delivered.
Warning:
This is not finished, and the API may change at any point!
Args:
message: The message to set as delivered
"""
data = {
"message_ids[0]": message.id,
"thread_ids[%s][0]" % message.thread.id: message.id,
}
j = self.session._payload_post("/ajax/mercury/delivery_receipts.php", data)
def _read_status(self, read, threads, at):
data = {
"watermarkTimestamp": _util.datetime_to_millis(at),
"shouldSendReadReceipt": "true",
}
for threads in threads:
data["ids[{}]".format(thread.id)] = "true" if read else "false"
j = self.session._payload_post("/ajax/mercury/change_read_status.php", data)
def mark_as_read(
self, threads: Iterable[_threads.ThreadABC], at: datetime.datetime
):
"""Mark threads as read.
All messages inside the specified threads will be marked as read.
Args:
threads: Threads to set as read
at: Timestamp to signal the read cursor at
"""
return self._read_status(True, threads, at)
def mark_as_unread(
self, threads: Iterable[_threads.ThreadABC], at: datetime.datetime
):
"""Mark threads as unread.
All messages inside the specified threads will be marked as unread.
Args:
threads: Threads to set as unread
at: Timestamp to signal the read cursor at
"""
return self._read_status(False, threads, at)
def mark_as_seen(self, at: datetime.datetime):
# TODO: Documenting this
data = {"seen_timestamp": _util.datetime_to_millis(at)}
j = self.session._payload_post("/ajax/mercury/mark_seen.php", data)
def move_threads(
self, location: _models.ThreadLocation, threads: Iterable[_threads.ThreadABC]
):
"""Move threads to specified location.
Args:
location: INBOX, PENDING, ARCHIVED or OTHER
threads: Threads to move
"""
if location == _models.ThreadLocation.PENDING:
location = _models.ThreadLocation.OTHER
if location == _models.ThreadLocation.ARCHIVED:
data_archive = {}
data_unpin = {}
for thread in threads:
data_archive["ids[{}]".format(thread.id)] = "true"
data_unpin["ids[{}]".format(thread.id)] = "false"
j_archive = self.session._payload_post(
"/ajax/mercury/change_archived_status.php?dpr=1", data_archive
)
j_unpin = self.session._payload_post(
"/ajax/mercury/change_pinned_status.php?dpr=1", data_unpin
)
else:
data = {}
for i, thread in enumerate(threads):
data["{}[{}]".format(location.name.lower(), i)] = thread.id
j = self.session._payload_post("/ajax/mercury/move_threads.php", data)
def delete_threads(self, threads: Iterable[_threads.ThreadABC]):
"""Bulk delete threads.
Args:
threads: Threads to delete
Example:
>>> group = fbchat.Group(session=session, id="1234")
>>> client.delete_threads([group])
"""
_threads.ThreadABC._delete_many(self.session, (t.id for t in threads))
def delete_messages(self, messages: Iterable[_models.Message]):
"""Bulk delete specified messages.
Args:
messages: Messages to delete
Example:
>>> message1 = fbchat.Message(thread=thread, id="1234")
>>> message2 = fbchat.Message(thread=thread, id="2345")
>>> client.delete_threads([message1, message2])
"""
_models.Message._delete_many(self.session, (m.id for m in messages))

11
fbchat/_common.py Normal file
View File

@@ -0,0 +1,11 @@
import sys
import attr
import logging
log = logging.getLogger("fbchat")
# Enable kw_only if the python version supports it
kw_only = sys.version_info[:2] > (3, 5)
#: Default attrs settings for classes
attrs_default = attr.s(frozen=True, slots=True, kw_only=kw_only)

114
fbchat/_events/__init__.py Normal file
View File

@@ -0,0 +1,114 @@
import attr
import datetime
from ._common import attrs_event, Event, UnknownEvent, ThreadEvent
from ._client_payload import *
from ._delta_class import *
from ._delta_type import *
from .. import _exception, _threads, _models
from typing import Mapping
@attrs_event
class Typing(ThreadEvent):
"""Somebody started/stopped typing in a thread."""
#: ``True`` if the user started typing, ``False`` if they stopped
status = attr.ib(type=bool)
@classmethod
def _parse_orca(cls, session, data):
author = _threads.User(session=session, id=str(data["sender_fbid"]))
status = data["state"] == 1
return cls(author=author, thread=author, status=status)
@classmethod
def _parse(cls, session, data):
# TODO: Rename this method
author = _threads.User(session=session, id=str(data["sender_fbid"]))
thread = _threads.Group(session=session, id=str(data["thread"]))
status = data["state"] == 1
return cls(author=author, thread=thread, status=status)
@attrs_event
class FriendRequest(Event):
"""Somebody sent a friend request."""
#: The user that sent the request
author = attr.ib(type="_threads.User")
@classmethod
def _parse(cls, session, data):
author = _threads.User(session=session, id=str(data["from"]))
return cls(author=author)
@attrs_event
class Presence(Event):
"""The list of active statuses was updated.
Chat online presence update.
"""
# TODO: Document this better!
#: User ids mapped to their active status
statuses = attr.ib(type=Mapping[str, "_models.ActiveStatus"])
#: ``True`` if the list is fully updated and ``False`` if it's partially updated
full = attr.ib(type=bool)
@classmethod
def _parse(cls, session, data):
statuses = {
str(d["u"]): _models.ActiveStatus._from_orca_presence(d)
for d in data["list"]
}
return cls(statuses=statuses, full=data["list_type"] == "full")
def parse_events(session, topic, data):
# See Mqtt._configure_connect_options for information about these topics
try:
if topic == "/t_ms":
# `deltas` will always be available, since we're filtering out the things
# that don't have it earlier in the MQTT listener
for delta in data["deltas"]:
if delta["class"] == "ClientPayload":
yield from parse_client_payloads(session, delta)
continue
try:
event = parse_delta(session, delta)
if event: # Skip `None`
yield event
except _exception.ParseError:
raise
except Exception as e:
raise _exception.ParseError(
"Error parsing delta", data=delta
) from e
elif topic == "/thread_typing":
yield Typing._parse(session, data)
elif topic == "/orca_typing_notifications":
yield Typing._parse_orca(session, data)
elif topic == "/legacy_web":
if data["type"] == "jewel_requests_add":
yield FriendRequest._parse(session, data)
else:
yield UnknownEvent(source="/legacy_web", data=data)
elif topic == "/orca_presence":
yield Presence._parse(session, data)
else:
yield UnknownEvent(source=topic, data=data)
except _exception.ParseError:
raise
except Exception as e:
raise _exception.ParseError(
"Error parsing MQTT topic {}".format(topic), data=data
) from e

View File

@@ -0,0 +1,136 @@
import attr
import datetime
from ._common import attrs_event, UnknownEvent, ThreadEvent
from .. import _exception, _util, _threads, _models
from typing import Optional
@attrs_event
class ReactionEvent(ThreadEvent):
"""Somebody reacted to a message."""
#: Message that the user reacted to
message = attr.ib(type="_models.Message")
reaction = attr.ib(type=Optional[str])
"""The reaction.
Not limited to the ones in `Message.react`.
If ``None``, the reaction was removed.
"""
@classmethod
def _parse(cls, session, data):
thread = cls._get_thread(session, data)
return cls(
author=_threads.User(session=session, id=str(data["userId"])),
thread=thread,
message=_models.Message(thread=thread, id=data["messageId"]),
reaction=data["reaction"] if data["action"] == 0 else None,
)
@attrs_event
class UserStatusEvent(ThreadEvent):
#: Whether the user was blocked or unblocked
blocked = attr.ib(type=bool)
@classmethod
def _parse(cls, session, data):
return cls(
author=_threads.User(session=session, id=str(data["actorFbid"])),
thread=cls._get_thread(session, data),
blocked=not data["canViewerReply"],
)
@attrs_event
class LiveLocationEvent(ThreadEvent):
"""Somebody sent live location info."""
# TODO: This!
@classmethod
def _parse(cls, session, data):
from . import _location
thread = cls._get_thread(session, data)
for location_data in data["messageLiveLocations"]:
message = _models.Message(thread=thread, id=data["messageId"])
author = _threads.User(session=session, id=str(location_data["senderId"]))
location = _location.LiveLocationAttachment._from_pull(location_data)
return None
@attrs_event
class UnsendEvent(ThreadEvent):
"""Somebody unsent a message (which deletes it for everyone)."""
#: The unsent message
message = attr.ib(type="_models.Message")
#: When the message was unsent
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
thread = cls._get_thread(session, data)
return cls(
author=_threads.User(session=session, id=str(data["senderID"])),
thread=thread,
message=_models.Message(thread=thread, id=data["messageID"]),
at=_util.millis_to_datetime(data["deletionTimestamp"]),
)
@attrs_event
class MessageReplyEvent(ThreadEvent):
"""Somebody replied to a message."""
#: The sent message
message = attr.ib(type="_models.MessageData")
#: The message that was replied to
replied_to = attr.ib(type="_models.MessageData")
@classmethod
def _parse(cls, session, data):
metadata = data["message"]["messageMetadata"]
thread = cls._get_thread(session, metadata)
return cls(
author=_threads.User(session=session, id=str(metadata["actorFbId"])),
thread=thread,
message=_models.MessageData._from_reply(thread, data["message"]),
replied_to=_models.MessageData._from_reply(
thread, data["repliedToMessage"]
),
)
def parse_client_delta(session, data):
if "deltaMessageReaction" in data:
return ReactionEvent._parse(session, data["deltaMessageReaction"])
elif "deltaChangeViewerStatus" in data:
# TODO: Parse all `reason`
if data["deltaChangeViewerStatus"]["reason"] == 2:
return UserStatusEvent._parse(session, data["deltaChangeViewerStatus"])
elif "liveLocationData" in data:
return LiveLocationEvent._parse(session, data["liveLocationData"])
elif "deltaRecallMessageData" in data:
return UnsendEvent._parse(session, data["deltaRecallMessageData"])
elif "deltaMessageReply" in data:
return MessageReplyEvent._parse(session, data["deltaMessageReply"])
return UnknownEvent(source="client payload", data=data)
def parse_client_payloads(session, data):
payload = _util.parse_json("".join(chr(z) for z in data["payload"]))
try:
for delta in payload["deltas"]:
yield parse_client_delta(session, delta)
except _exception.ParseError:
raise
except Exception as e:
raise _exception.ParseError("Error parsing ClientPayload", data=payload) from e

62
fbchat/_events/_common.py Normal file
View File

@@ -0,0 +1,62 @@
import attr
import abc
from .._common import kw_only
from .. import _exception, _util, _threads
from typing import Any
#: Default attrs settings for events
attrs_event = attr.s(slots=True, kw_only=kw_only, frozen=True)
@attrs_event
class Event(metaclass=abc.ABCMeta):
"""Base class for all events."""
@classmethod
@abc.abstractmethod
def _parse(cls, session, data):
raise NotImplementedError
@staticmethod
def _get_thread(session, data):
# TODO: Handle pages? Is it even possible?
key = data["threadKey"]
if "threadFbId" in key:
return _threads.Group(session=session, id=str(key["threadFbId"]))
elif "otherUserFbId" in key:
return _threads.User(session=session, id=str(key["otherUserFbId"]))
raise _exception.ParseError("Could not find thread data", data=data)
@attrs_event
class UnknownEvent(Event):
"""Represent an unknown event."""
#: Some data describing the unknown event's origin
source = attr.ib(type=str)
#: The unknown data. This cannot be relied on, it's only for debugging purposes.
data = attr.ib(type=Any)
@classmethod
def _parse(cls, session, data):
raise NotImplementedError
@attrs_event
class ThreadEvent(Event):
"""Represent an event that was done by a user/page in a thread."""
#: The person who did the action
author = attr.ib(type="_threads.User") # Or Union[User, Page]?
#: Thread that the action was done in
thread = attr.ib(type="_threads.ThreadABC")
@classmethod
def _parse_metadata(cls, session, data):
metadata = data["messageMetadata"]
author = _threads.User(session=session, id=metadata["actorFbId"])
thread = cls._get_thread(session, metadata)
at = _util.millis_to_datetime(int(metadata["timestamp"]))
return author, thread, at

View File

@@ -0,0 +1,214 @@
import attr
import datetime
from ._common import attrs_event, Event, UnknownEvent, ThreadEvent
from . import _delta_type
from .. import _util, _threads, _models
from typing import Sequence, Optional
@attrs_event
class PeopleAdded(ThreadEvent):
"""somebody added people to a group thread."""
# TODO: Add message id
thread = attr.ib(type="_threads.Group") # Set the correct type
#: The people who got added
added = attr.ib(type=Sequence["_threads.User"])
#: When the people were added
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
added = [
# TODO: Parse user name
_threads.User(session=session, id=x["userFbId"])
for x in data["addedParticipants"]
]
return cls(author=author, thread=thread, added=added, at=at)
@attrs_event
class PersonRemoved(ThreadEvent):
"""Somebody removed a person from a group thread."""
# TODO: Add message id
thread = attr.ib(type="_threads.Group") # Set the correct type
#: Person who got removed
removed = attr.ib(type="_models.Message")
#: When the person were removed
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
removed = _threads.User(session=session, id=data["leftParticipantFbId"])
return cls(author=author, thread=thread, removed=removed, at=at)
@attrs_event
class TitleSet(ThreadEvent):
"""Somebody changed a group's title."""
thread = attr.ib(type="_threads.Group") # Set the correct type
#: The new title
title = attr.ib(type=str)
#: When the title was set
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
return cls(author=author, thread=thread, title=data["name"], at=at)
@attrs_event
class UnfetchedThreadEvent(Event):
"""A message was received, but the data must be fetched manually.
Use `Message.fetch` to retrieve the message data.
This is usually used when somebody changes the group's photo, or when a new pending
group is created.
"""
# TODO: Present this in a way that users can fetch the changed group photo easily
#: The thread the message was sent to
thread = attr.ib(type="_threads.ThreadABC")
#: The message
message = attr.ib(type=Optional["_models.Message"])
@classmethod
def _parse(cls, session, data):
thread = cls._get_thread(session, data)
message = None
if "messageId" in data:
message = _models.Message(thread=thread, id=data["messageId"])
return cls(thread=thread, message=message)
@attrs_event
class MessagesDelivered(ThreadEvent):
"""Somebody marked messages as delivered in a thread."""
#: The messages that were marked as delivered
messages = attr.ib(type=Sequence["_models.Message"])
#: When the messages were delivered
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
thread = cls._get_thread(session, data)
if "actorFbId" in data:
author = _threads.User(session=session, id=data["actorFbId"])
else:
author = thread
messages = [_models.Message(thread=thread, id=x) for x in data["messageIds"]]
at = _util.millis_to_datetime(int(data["deliveredWatermarkTimestampMs"]))
return cls(author=author, thread=thread, messages=messages, at=at)
@attrs_event
class ThreadsRead(Event):
"""Somebody marked threads as read/seen."""
#: The person who marked the threads as read
author = attr.ib(type="_threads.ThreadABC")
#: The threads that were marked as read
threads = attr.ib(type=Sequence["_threads.ThreadABC"])
#: When the threads were read
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse_read_receipt(cls, session, data):
author = _threads.User(session=session, id=data["actorFbId"])
thread = cls._get_thread(session, data)
at = _util.millis_to_datetime(int(data["actionTimestampMs"]))
return cls(author=author, threads=[thread], at=at)
@classmethod
def _parse(cls, session, data):
threads = [
cls._get_thread(session, {"threadKey": x}) for x in data["threadKeys"]
]
at = _util.millis_to_datetime(int(data["actionTimestamp"]))
return cls(author=session.user, threads=threads, at=at)
@attrs_event
class MessageEvent(ThreadEvent):
"""Somebody sent a message to a thread."""
#: The sent message
message = attr.ib(type="_models.Message")
#: When the threads were read
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
message = _models.MessageData._from_pull(
thread, data, author=author.id, created_at=at,
)
return cls(author=author, thread=thread, message=message, at=at)
@attrs_event
class ThreadFolder(Event):
"""A thread was created in a folder.
Somebody that isn't connected with you on either Facebook or Messenger sends a
message. After that, you need to use `ThreadABC.fetch_messages` to actually read it.
"""
# TODO: Finish this
#: The created thread
thread = attr.ib(type="_threads.ThreadABC")
#: The folder/location
folder = attr.ib(type="_models.ThreadLocation")
@classmethod
def _parse(cls, session, data):
thread = cls._get_thread(session, data)
folder = _models.ThreadLocation._parse(data["folder"])
return cls(thread=thread, folder=folder)
def parse_delta(session, data):
class_ = data["class"]
if class_ == "AdminTextMessage":
return _delta_type.parse_admin_message(session, data)
elif class_ == "ParticipantsAddedToGroupThread":
return PeopleAdded._parse(session, data)
elif class_ == "ParticipantLeftGroupThread":
return PersonRemoved._parse(session, data)
elif class_ == "MarkFolderSeen":
# TODO: Finish this
folders = [_models.ThreadLocation._parse(folder) for folder in data["folders"]]
at = _util.millis_to_datetime(int(data["timestamp"]))
return None
elif class_ == "ThreadName":
return TitleSet._parse(session, data)
elif class_ == "ForcedFetch":
return UnfetchedThreadEvent._parse(session, data)
elif class_ == "DeliveryReceipt":
return MessagesDelivered._parse(session, data)
elif class_ == "ReadReceipt":
return ThreadsRead._parse_read_receipt(session, data)
elif class_ == "MarkRead":
return ThreadsRead._parse(session, data)
elif class_ == "NoOp":
# Skip "no operation" events
return None
elif class_ == "NewMessage":
return MessageEvent._parse(session, data)
elif class_ == "ThreadFolder":
return ThreadFolder._parse(session, data)
elif class_ == "ClientPayload":
raise ValueError("This is implemented in `parse_events`")
return UnknownEvent(source="Delta class", data=data)

View File

@@ -0,0 +1,331 @@
import attr
import datetime
from ._common import attrs_event, Event, UnknownEvent, ThreadEvent
from .. import _util, _threads, _models
from typing import Sequence, Optional
@attrs_event
class ColorSet(ThreadEvent):
"""Somebody set the color in a thread."""
#: The new color. Not limited to the ones in `ThreadABC.set_color`
color = attr.ib(type=str)
#: When the color was set
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
color = _threads.ThreadABC._parse_color(data["untypedData"]["theme_color"])
return cls(author=author, thread=thread, color=color, at=at)
@attrs_event
class EmojiSet(ThreadEvent):
"""Somebody set the emoji in a thread."""
#: The new emoji
emoji = attr.ib(type=str)
#: When the emoji was set
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
emoji = data["untypedData"]["thread_icon"]
return cls(author=author, thread=thread, emoji=emoji, at=at)
@attrs_event
class NicknameSet(ThreadEvent):
"""Somebody set the nickname of a person in a thread."""
#: The person whose nickname was set
subject = attr.ib(type=str)
#: The new nickname. If ``None``, the nickname was cleared
nickname = attr.ib(type=Optional[str])
#: When the nickname was set
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
subject = _threads.User(
session=session, id=data["untypedData"]["participant_id"]
)
nickname = data["untypedData"]["nickname"] or None # None if ""
return cls(
author=author, thread=thread, subject=subject, nickname=nickname, at=at
)
@attrs_event
class AdminsAdded(ThreadEvent):
"""Somebody added admins to a group."""
#: The people that were set as admins
added = attr.ib(type=Sequence["_threads.User"])
#: When the admins were added
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
subject = _threads.User(session=session, id=data["untypedData"]["TARGET_ID"])
return cls(author=author, thread=thread, added=[subject], at=at)
@attrs_event
class AdminsRemoved(ThreadEvent):
"""Somebody removed admins from a group."""
#: The people that were removed as admins
removed = attr.ib(type=Sequence["_threads.User"])
#: When the admins were removed
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
subject = _threads.User(session=session, id=data["untypedData"]["TARGET_ID"])
return cls(author=author, thread=thread, removed=[subject], at=at)
@attrs_event
class ApprovalModeSet(ThreadEvent):
"""Somebody changed the approval mode in a group."""
require_admin_approval = attr.ib(type=bool)
#: When the approval mode was set
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
raa = data["untypedData"]["APPROVAL_MODE"] == "1"
return cls(author=author, thread=thread, require_admin_approval=raa, at=at)
@attrs_event
class CallStarted(ThreadEvent):
"""Somebody started a call."""
#: When the call was started
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
return cls(author=author, thread=thread, at=at)
@attrs_event
class CallEnded(ThreadEvent):
"""Somebody ended a call."""
#: How long the call took
duration = attr.ib(type=datetime.timedelta)
#: When the call ended
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
duration = _util.seconds_to_timedelta(int(data["untypedData"]["call_duration"]))
return cls(author=author, thread=thread, duration=duration, at=at)
@attrs_event
class CallJoined(ThreadEvent):
"""Somebody joined a call."""
#: When the call ended
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
return cls(author=author, thread=thread, at=at)
@attrs_event
class PollCreated(ThreadEvent):
"""Somebody created a group poll."""
#: The new poll
poll = attr.ib(type="_models.Poll")
#: When the poll was created
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
poll_data = _util.parse_json(data["untypedData"]["question_json"])
poll = _models.Poll._from_graphql(session, poll_data)
return cls(author=author, thread=thread, poll=poll, at=at)
@attrs_event
class PollVoted(ThreadEvent):
"""Somebody voted in a group poll."""
#: The updated poll
poll = attr.ib(type="_models.Poll")
#: Ids of the voted options
added_ids = attr.ib(type=Sequence[str])
#: Ids of the un-voted options
removed_ids = attr.ib(type=Sequence[str])
#: When the poll was voted in
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
poll_data = _util.parse_json(data["untypedData"]["question_json"])
poll = _models.Poll._from_graphql(session, poll_data)
added_ids = _util.parse_json(data["untypedData"]["added_option_ids"])
removed_ids = _util.parse_json(data["untypedData"]["removed_option_ids"])
return cls(
author=author,
thread=thread,
poll=poll,
added_ids=[str(x) for x in added_ids],
removed_ids=[str(x) for x in removed_ids],
at=at,
)
@attrs_event
class PlanCreated(ThreadEvent):
"""Somebody created a plan in a group."""
#: The new plan
plan = attr.ib(type="_models.PlanData")
#: When the plan was created
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
plan = _models.PlanData._from_pull(session, data["untypedData"])
return cls(author=author, thread=thread, plan=plan, at=at)
@attrs_event
class PlanEnded(ThreadEvent):
"""A plan ended."""
#: The ended plan
plan = attr.ib(type="_models.PlanData")
#: When the plan ended
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
plan = _models.PlanData._from_pull(session, data["untypedData"])
return cls(author=author, thread=thread, plan=plan, at=at)
@attrs_event
class PlanEdited(ThreadEvent):
"""Somebody changed a plan in a group."""
#: The updated plan
plan = attr.ib(type="_models.PlanData")
#: When the plan was updated
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
plan = _models.PlanData._from_pull(session, data["untypedData"])
return cls(author=author, thread=thread, plan=plan, at=at)
@attrs_event
class PlanDeleted(ThreadEvent):
"""Somebody removed a plan in a group."""
#: The removed plan
plan = attr.ib(type="_models.PlanData")
#: When the plan was removed
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
plan = _models.PlanData._from_pull(session, data["untypedData"])
return cls(author=author, thread=thread, plan=plan, at=at)
@attrs_event
class PlanResponded(ThreadEvent):
"""Somebody responded to a plan in a group."""
#: The plan that was responded to
plan = attr.ib(type="_models.PlanData")
#: Whether the author will go to the plan or not
take_part = attr.ib(type=bool)
#: When the plan was removed
at = attr.ib(type=datetime.datetime)
@classmethod
def _parse(cls, session, data):
author, thread, at = cls._parse_metadata(session, data)
plan = _models.PlanData._from_pull(session, data["untypedData"])
take_part = data["untypedData"]["guest_status"] == "GOING"
return cls(author=author, thread=thread, plan=plan, take_part=take_part, at=at)
def parse_admin_message(session, data):
type_ = data["type"]
if type_ == "change_thread_theme":
return ColorSet._parse(session, data)
elif type_ == "change_thread_icon":
return EmojiSet._parse(session, data)
elif type_ == "change_thread_nickname":
return NicknameSet._parse(session, data)
elif type_ == "change_thread_admins":
event_type = data["untypedData"]["ADMIN_EVENT"]
if event_type == "add_admin":
return AdminsAdded._parse(session, data)
elif event_type == "remove_admin":
return AdminsRemoved._parse(session, data)
else:
pass
elif type_ == "change_thread_approval_mode":
return ApprovalModeSet._parse(session, data)
elif type_ == "instant_game_update":
pass # TODO: This
elif type_ == "messenger_call_log": # Previously "rtc_call_log"
event_type = data["untypedData"]["event"]
if event_type == "group_call_started":
return CallStarted._parse(session, data)
elif event_type in ["group_call_ended", "one_on_one_call_ended"]:
return CallEnded._parse(session, data)
else:
pass
elif type_ == "participant_joined_group_call":
return CallJoined._parse(session, data)
elif type_ == "group_poll":
event_type = data["untypedData"]["event_type"]
if event_type == "question_creation":
return PollCreated._parse(session, data)
elif event_type == "update_vote":
return PollVoted._parse(session, data)
else:
pass
elif type_ == "lightweight_event_create":
return PlanCreated._parse(session, data)
elif type_ == "lightweight_event_notify":
return PlanEnded._parse(session, data)
elif type_ == "lightweight_event_update":
return PlanEdited._parse(session, data)
elif type_ == "lightweight_event_delete":
return PlanDeleted._parse(session, data)
elif type_ == "lightweight_event_rsvp":
return PlanResponded._parse(session, data)
return UnknownEvent(source="Delta type", data=data)

165
fbchat/_exception.py Normal file
View File

@@ -0,0 +1,165 @@
import attr
import requests
from typing import Any
# Not frozen, since that doesn't work in PyPy
@attr.s(slots=True, auto_exc=True)
class FacebookError(Exception):
"""Base class for all custom exceptions raised by ``fbchat``.
All exceptions in the module inherit this.
"""
#: A message describing the error
message = attr.ib(type=str)
@attr.s(slots=True, auto_exc=True)
class HTTPError(FacebookError):
"""Base class for errors with the HTTP(s) connection to Facebook."""
#: The returned HTTP status code, if relevant
status_code = attr.ib(None, type=int)
def __str__(self):
if not self.status_code:
return self.message
return "Got {} response: {}".format(self.status_code, self.message)
@attr.s(slots=True, auto_exc=True)
class ParseError(FacebookError):
"""Raised when we fail parsing a response from Facebook.
This may contain sensitive data, so should not be logged to file.
"""
data = attr.ib(type=Any)
"""The data that triggered the error.
The format of this cannot be relied on, it's only for debugging purposes.
"""
def __str__(self):
msg = "{}. Please report this, along with the data below!\n{}"
return msg.format(self.message, self.data)
@attr.s(slots=True, auto_exc=True)
class NotLoggedIn(FacebookError):
"""Raised by Facebook if the client has been logged out."""
@attr.s(slots=True, auto_exc=True)
class ExternalError(FacebookError):
"""Base class for errors that Facebook return."""
#: The error message that Facebook returned (Possibly in the user's own language)
description = attr.ib(type=str)
#: The error code that Facebook returned
code = attr.ib(None, type=int)
def __str__(self):
if self.code:
return "#{} {}: {}".format(self.code, self.message, self.description)
return "{}: {}".format(self.message, self.description)
@attr.s(slots=True, auto_exc=True)
class GraphQLError(ExternalError):
"""Raised by Facebook if there was an error in the GraphQL query."""
# TODO: Handle multiple errors
#: Query debug information
debug_info = attr.ib(None, type=str)
def __str__(self):
if self.debug_info:
return "{}, {}".format(super().__str__(), self.debug_info)
return super().__str__()
@attr.s(slots=True, auto_exc=True)
class InvalidParameters(ExternalError):
"""Raised by Facebook if:
- Some function supplied invalid parameters.
- Some content is not found.
- Some content is no longer available.
"""
@attr.s(slots=True, auto_exc=True)
class PleaseRefresh(ExternalError):
"""Raised by Facebook if the client has been inactive for too long.
This error usually happens after 1-2 days of inactivity.
"""
code = attr.ib(1357004)
def handle_payload_error(j):
if "error" not in j:
return
code = j["error"]
if code == 1357001:
raise NotLoggedIn(j["errorSummary"])
elif code == 1357004:
error_cls = PleaseRefresh
elif code in (1357031, 1545010, 1545003):
error_cls = InvalidParameters
else:
error_cls = ExternalError
raise error_cls(j["errorSummary"], description=j["errorDescription"], code=code)
def handle_graphql_errors(j):
errors = []
if j.get("error"):
errors = [j["error"]]
if "errors" in j:
errors = j["errors"]
if errors:
error = errors[0] # TODO: Handle multiple errors
# TODO: Use `severity` and `description`
raise GraphQLError(
# TODO: What data is always available?
message=error.get("summary", "Unknown error"),
description=error.get("message", ""),
code=error.get("code"),
debug_info=error.get("debug_info"),
)
def handle_http_error(code):
if code == 404:
raise HTTPError(
"This might be because you provided an invalid id"
+ " (Facebook usually require integer ids)",
status_code=code,
)
if code == 500:
raise HTTPError(
"There is probably an error on the endpoint, or it might be rate limited",
status_code=code,
)
if 400 <= code < 600:
raise HTTPError("Failed sending request", status_code=code)
def handle_requests_error(e):
if isinstance(e, requests.ConnectionError):
raise HTTPError("Connection error") from e
if isinstance(e, requests.HTTPError):
pass # Raised when using .raise_for_status, so should never happen
if isinstance(e, requests.URLRequired):
pass # Should never happen, we always prove valid URLs
if isinstance(e, requests.TooManyRedirects):
pass # TODO: Consider using allow_redirects=False to prevent this
if isinstance(e, requests.Timeout):
pass # Should never happen, we don't set timeouts
raise HTTPError("Requests error") from e

View File

@@ -0,0 +1,45 @@
"""Everything in this module is taken from the excellent trio project.
Having the public path in .__module__ attributes is important for:
- exception names in printed tracebacks
- ~sphinx :show-inheritance:~
- deprecation warnings
- pickle
- probably other stuff
"""
import os
def fixup_module_metadata(namespace):
def fix_one(qualname, name, obj):
# Custom extension, to handle classmethods, staticmethods and properties
if isinstance(obj, (classmethod, staticmethod)):
obj = obj.__func__
if isinstance(obj, property):
obj = obj.fget
mod = getattr(obj, "__module__", None)
if mod is not None and mod.startswith("fbchat."):
obj.__module__ = "fbchat"
# Modules, unlike everything else in Python, put fully-qualitied
# names into their __name__ attribute. We check for "." to avoid
# rewriting these.
if hasattr(obj, "__name__") and "." not in obj.__name__:
obj.__name__ = name
obj.__qualname__ = qualname
if isinstance(obj, type):
# Fix methods
for attr_name, attr_value in obj.__dict__.items():
fix_one(objname + "." + attr_name, attr_name, attr_value)
for objname, obj in namespace.items():
if not objname.startswith("_"): # ignore private attributes
fix_one(objname, objname, obj)
# Allow disabling this when running Sphinx
# This is done so that Sphinx autodoc can detect the file's source
# TODO: Find a better way to detect when we're running Sphinx!
if os.environ.get("_FBCHAT_DISABLE_FIX_MODULE_METADATA") == "1":
fixup_module_metadata = lambda namespace: None

235
fbchat/_graphql.py Normal file
View File

@@ -0,0 +1,235 @@
import json
import re
from ._common import log
from . import _util, _exception
# Shameless copy from https://stackoverflow.com/a/8730674
FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
WHITESPACE = re.compile(r"[ \t\n\r]*", FLAGS)
class ConcatJSONDecoder(json.JSONDecoder):
def decode(self, s, _w=WHITESPACE.match):
s_len = len(s)
objs = []
end = 0
while end != s_len:
obj, end = self.raw_decode(s, idx=_w(s, end).end())
end = _w(s, end).end()
objs.append(obj)
return objs
# End shameless copy
def queries_to_json(*queries):
"""
Queries should be a list of GraphQL objects
"""
rtn = {}
for i, query in enumerate(queries):
rtn["q{}".format(i)] = query
return _util.json_minimal(rtn)
def response_to_json(text):
text = _util.strip_json_cruft(text) # Usually only needed in some error cases
try:
j = json.loads(text, cls=ConcatJSONDecoder)
except Exception as e:
raise _exception.ParseError("Error while parsing JSON", data=text) from e
rtn = [None] * (len(j))
for x in j:
if "error_results" in x:
del rtn[-1]
continue
_exception.handle_payload_error(x)
[(key, value)] = x.items()
_exception.handle_graphql_errors(value)
if "response" in value:
rtn[int(key[1:])] = value["response"]
else:
rtn[int(key[1:])] = value["data"]
log.debug(rtn)
return rtn
def from_query(query, params):
return {"priority": 0, "q": query, "query_params": params}
def from_query_id(query_id, params):
return {"query_id": query_id, "query_params": params}
def from_doc(doc, params):
return {"doc": doc, "query_params": params}
def from_doc_id(doc_id, params):
return {"doc_id": doc_id, "query_params": params}
FRAGMENT_USER = """
QueryFragment User: User {
id,
name,
first_name,
last_name,
profile_picture.width(<pic_size>).height(<pic_size>) {
uri
},
is_viewer_friend,
url,
gender,
viewer_affinity
}
"""
FRAGMENT_GROUP = """
QueryFragment Group: MessageThread {
name,
thread_key {
thread_fbid
},
image {
uri
},
is_group_thread,
all_participants {
nodes {
messaging_actor {
__typename,
id
}
}
},
customization_info {
participant_customizations {
participant_id,
nickname
},
outgoing_bubble_color,
emoji
},
thread_admins {
id
},
group_approval_queue {
nodes {
requester {
id
}
}
},
approval_mode,
joinable_mode {
mode,
link
},
event_reminders {
nodes {
id,
lightweight_event_creator {
id
},
time,
location_name,
event_title,
event_reminder_members {
edges {
node {
id
},
guest_list_state
}
}
}
}
}
"""
FRAGMENT_PAGE = """
QueryFragment Page: Page {
id,
name,
profile_picture.width(32).height(32) {
uri
},
url,
category_type,
city {
name
}
}
"""
SEARCH_USER = (
"""
Query SearchUser(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes {
@User
}
}
}
}
"""
+ FRAGMENT_USER
)
SEARCH_GROUP = (
"""
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes {
@Group
}
}
}
}
"""
+ FRAGMENT_GROUP
)
SEARCH_PAGE = (
"""
Query SearchPage(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@Page
}
}
}
}
"""
+ FRAGMENT_PAGE
)
SEARCH_THREAD = (
"""
Query SearchThread(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {
__typename,
@User,
@Group,
@Page
}
}
}
}
"""
+ FRAGMENT_USER
+ FRAGMENT_GROUP
+ FRAGMENT_PAGE
)

401
fbchat/_listen.py Normal file
View File

@@ -0,0 +1,401 @@
import attr
import random
import paho.mqtt.client
import requests
from ._common import log, kw_only
from . import _util, _exception, _session, _graphql, _events
from typing import Iterable, Optional, Mapping
HOST = "edge-chat.facebook.com"
TOPICS = [
# Things that happen in chats (e.g. messages)
"/t_ms",
# Group typing notifications
"/thread_typing",
# Private chat typing notifications
"/orca_typing_notifications",
# Active notifications
"/orca_presence",
# Other notifications not related to chats (e.g. friend requests)
"/legacy_web",
# Facebook's continuous error reporting/logging?
"/br_sr",
# Response to /br_sr
"/sr_res",
# Data about user-to-user calls
# TODO: Investigate the response from this! (A bunch of binary data)
# "/t_rtc",
# TODO: Find out what this does!
# TODO: Investigate the response from this! (A bunch of binary data)
# "/t_p",
# TODO: Find out what this does!
"/webrtc",
# TODO: Find out what this does!
"/onevc",
# TODO: Find out what this does!
"/notify_disconnect",
# Old, no longer active topics
# These are here just in case something interesting pops up
"/inbox",
"/mercury",
"/messaging_events",
"/orca_message_notifications",
"/pp",
"/webrtc_response",
]
def get_cookie_header(session: requests.Session, url: str) -> str:
"""Extract a cookie header from a requests session."""
# The cookies are extracted this way to make sure they're escaped correctly
return requests.cookies.get_cookie_header(
session.cookies, requests.Request("GET", url),
)
def generate_session_id() -> int:
"""Generate a random session ID between 1 and 9007199254740991."""
return random.randint(1, 2 ** 53)
def mqtt_factory() -> paho.mqtt.client.Client:
# Configure internal MQTT handler
mqtt = paho.mqtt.client.Client(
client_id="mqttwsclient",
clean_session=True,
protocol=paho.mqtt.client.MQTTv31,
transport="websockets",
)
mqtt.enable_logger()
# mqtt.max_inflight_messages_set(20) # The rest will get queued
# mqtt.max_queued_messages_set(0) # Unlimited messages can be queued
# mqtt.message_retry_set(20) # Retry sending for at least 20 seconds
# mqtt.reconnect_delay_set(min_delay=1, max_delay=120)
mqtt.tls_set()
mqtt.connect_async(HOST, 443, keepalive=10)
return mqtt
def fetch_sequence_id(session: _session.Session) -> int:
"""Fetch sequence ID."""
params = {
"limit": 0,
"tags": ["INBOX"],
"before": None,
"includeDeliveryReceipts": False,
"includeSeqID": True,
}
log.debug("Fetching MQTT sequence ID")
# Same doc id as in `Client.fetch_threads`
(j,) = session._graphql_requests(_graphql.from_doc_id("1349387578499440", params))
sequence_id = j["viewer"]["message_threads"]["sync_sequence_id"]
if not sequence_id:
raise _exception.NotLoggedIn("Failed fetching sequence id")
return int(sequence_id)
@attr.s(slots=True, kw_only=kw_only, eq=False)
class Listener:
"""Listen to incoming Facebook events.
Initialize a connection to the Facebook MQTT service.
Args:
session: The session to use when making requests.
chat_on: Whether ...
foreground: Whether ...
Example:
>>> listener = fbchat.Listener(session, chat_on=True, foreground=True)
"""
session = attr.ib(type=_session.Session)
_chat_on = attr.ib(type=bool)
_foreground = attr.ib(type=bool)
_mqtt = attr.ib(factory=mqtt_factory, type=paho.mqtt.client.Client)
_sync_token = attr.ib(None, type=Optional[str])
_sequence_id = attr.ib(None, type=Optional[int])
_tmp_events = attr.ib(factory=list, type=Iterable[_events.Event])
def __attrs_post_init__(self):
# Configure callbacks
self._mqtt.on_message = self._on_message_handler
self._mqtt.on_connect = self._on_connect_handler
def _handle_ms(self, j):
"""Handle /t_ms special logic.
Returns whether to continue parsing the message.
"""
# TODO: Merge this with the parsing in _events
# Update sync_token when received
# This is received in the first message after we've created a messenger
# sync queue.
if "syncToken" in j and "firstDeltaSeqId" in j:
self._sync_token = j["syncToken"]
self._sequence_id = j["firstDeltaSeqId"]
return False
if "errorCode" in j:
error = j["errorCode"]
# TODO: 'F\xfa\x84\x8c\x85\xf8\xbc-\x88 FB_PAGES_INSUFFICIENT_PERMISSION\x00'
if error in ("ERROR_QUEUE_NOT_FOUND", "ERROR_QUEUE_OVERFLOW"):
# ERROR_QUEUE_NOT_FOUND means that the queue was deleted, since too
# much time passed, or that it was simply missing
# ERROR_QUEUE_OVERFLOW means that the sequence id was too small, so
# the desired events could not be retrieved
log.error(
"The MQTT listener was disconnected for too long,"
" events may have been lost"
)
self._sync_token = None
self._sequence_id = None
return False
log.error("MQTT error code %s received", error)
return False
# Update last sequence id
# Except for the two cases above, this is always received
self._sequence_id = j["lastIssuedSeqId"]
return True
def _on_message_handler(self, client, userdata, message):
# Parse payload JSON
try:
j = _util.parse_json(message.payload.decode("utf-8"))
except (_exception.FacebookError, UnicodeDecodeError):
log.debug(message.payload)
log.exception("Failed parsing MQTT data on %s as JSON", message.topic)
return
log.debug("MQTT payload: %s, %s", message.topic, j)
if message.topic == "/t_ms":
if not self._handle_ms(j):
return
try:
# TODO: Don't handle this in a callback
self._tmp_events = list(
_events.parse_events(self.session, message.topic, j)
)
except _exception.ParseError:
log.exception("Failed parsing MQTT data")
def _on_connect_handler(self, client, userdata, flags, rc):
if rc == 21:
raise _exception.FacebookError(
"Failed connecting. Maybe your cookies are wrong?"
)
if rc != 0:
err = paho.mqtt.client.connack_string(rc)
log.error("MQTT Connection Error: %s", err)
return # Don't try to send publish if the connection failed
self._messenger_queue_publish()
def _messenger_queue_publish(self):
# configure receiving messages.
payload = {
"sync_api_version": 10,
"max_deltas_able_to_process": 1000,
"delta_batch_size": 500,
"encoding": "JSON",
"entity_fbid": self.session.user.id,
}
# If we don't have a sync_token, create a new messenger queue
# This is done so that across reconnects, if we've received a sync token, we
# SHOULD receive a piece of data in /t_ms exactly once!
if self._sync_token is None:
topic = "/messenger_sync_create_queue"
payload["initial_titan_sequence_id"] = str(self._sequence_id)
payload["device_params"] = None
else:
topic = "/messenger_sync_get_diffs"
payload["last_seq_id"] = str(self._sequence_id)
payload["sync_token"] = self._sync_token
self._mqtt.publish(topic, _util.json_minimal(payload), qos=1)
def _configure_connect_options(self):
# Generate a new session ID on each reconnect
session_id = generate_session_id()
username = {
# The user ID
"u": self.session.user.id,
# Session ID
"s": session_id,
# Active status setting
"chat_on": self._chat_on,
# foreground_state - Whether the window is focused
"fg": self._foreground,
# Can be any random ID
"d": self.session._client_id,
# Application ID, taken from facebook.com
"aid": 219994525426954,
# MQTT extension by FB, allows making a SUBSCRIBE while CONNECTing
"st": TOPICS,
# MQTT extension by FB, allows making a PUBLISH while CONNECTing
# Using this is more efficient, but the same can be acheived with:
# def on_connect(*args):
# mqtt.publish(topic, payload, qos=1)
# mqtt.on_connect = on_connect
# TODO: For some reason this doesn't work!
"pm": [
# {
# "topic": topic,
# "payload": payload,
# "qos": 1,
# "messageId": 65536,
# }
],
# Unknown parameters
"cp": 3,
"ecp": 10,
"ct": "websocket",
"mqtt_sid": "",
"dc": "",
"no_auto_fg": True,
"gas": None,
"pack": [],
}
self._mqtt.username_pw_set(_util.json_minimal(username))
headers = {
"Cookie": get_cookie_header(
self.session._session, "https://edge-chat.facebook.com/chat"
),
"User-Agent": self.session._session.headers["User-Agent"],
"Origin": "https://www.facebook.com",
"Host": HOST,
}
# TODO: Is region (lla | atn | odn | others?) important?
self._mqtt.ws_set_options(
path="/chat?sid={}".format(session_id), headers=headers
)
def _reconnect(self):
# Try reconnecting
self._configure_connect_options()
try:
self._mqtt.reconnect()
except (
# Taken from .loop_forever
paho.mqtt.client.socket.error,
OSError,
paho.mqtt.client.WebsocketConnectionError,
) as e:
log.debug("MQTT reconnection failed: %s", e)
# Wait before reconnecting
self._mqtt._reconnect_wait()
def listen(self) -> Iterable[_events.Event]:
"""Run the listening loop continually.
This is a blocking call, that will yield events as they arrive.
This will automatically reconnect on errors, except if the errors are one of
`PleaseRefresh` or `NotLoggedIn`.
Example:
Print events continually.
>>> for event in listener.listen():
... print(event)
"""
if self._sequence_id is None:
self._sequence_id = fetch_sequence_id(self.session)
# Make sure we're connected
while True:
# Beware, internal API, may have to change this to something more stable!
if self._mqtt._state == paho.mqtt.client.mqtt_cs_connect_async:
self._reconnect()
else:
break
while True:
rc = self._mqtt.loop(timeout=1.0)
# The sequence ID was reset in _handle_ms
# TODO: Signal to the user that they should reload their data!
if self._sequence_id is None:
self._sequence_id = fetch_sequence_id(self.session)
self._messenger_queue_publish()
# If disconnect() has been called
# Beware, internal API, may have to change this to something more stable!
if self._mqtt._state == paho.mqtt.client.mqtt_cs_disconnecting:
break # Stop listening
if rc != paho.mqtt.client.MQTT_ERR_SUCCESS:
# If known/expected error
if rc == paho.mqtt.client.MQTT_ERR_CONN_LOST:
log.warning("Connection lost, retrying")
elif rc == paho.mqtt.client.MQTT_ERR_NOMEM:
# This error is wrongly classified
# See https://github.com/eclipse/paho.mqtt.python/issues/340
log.warning("Connection error, retrying")
elif rc == paho.mqtt.client.MQTT_ERR_CONN_REFUSED:
raise _exception.NotLoggedIn("MQTT connection refused")
else:
err = paho.mqtt.client.error_string(rc)
log.error("MQTT Error: %s", err)
self._reconnect()
if self._tmp_events:
yield from self._tmp_events
self._tmp_events = []
def disconnect(self) -> None:
"""Disconnect the MQTT listener.
Can be called while listening, which will stop the listening loop.
The `Listener` object should not be used after this is called!
Example:
Stop the listener when recieving a message with the text "/stop"
>>> for event in listener.listen():
... if isinstance(event, fbchat.MessageEvent):
... if event.message.text == "/stop":
... listener.disconnect() # Almost the same "break"
"""
self._mqtt.disconnect()
def set_foreground(self, value: bool) -> None:
"""Set the `foreground` value while listening."""
# TODO: Document what this actually does!
payload = _util.json_minimal({"foreground": value})
info = self._mqtt.publish("/foreground_state", payload=payload, qos=1)
self._foreground = value
# TODO: We can't wait for this, since the loop is running within the same thread
# info.wait_for_publish()
def set_chat_on(self, value: bool) -> None:
"""Set the `chat_on` value while listening."""
# TODO: Document what this actually does!
# TODO: Is this the right request to make?
data = {"make_user_available_when_in_foreground": value}
payload = _util.json_minimal(data)
info = self._mqtt.publish("/set_client_settings", payload=payload, qos=1)
self._chat_on = value
# TODO: We can't wait for this, since the loop is running within the same thread
# info.wait_for_publish()
# def send_additional_contacts(self, additional_contacts):
# payload = _util.json_minimal({"additional_contacts": additional_contacts})
# info = self._mqtt.publish("/send_additional_contacts", payload=payload, qos=1)
#
# def browser_close(self):
# info = self._mqtt.publish("/browser_close", payload=b"{}", qos=1)

View File

@@ -0,0 +1,9 @@
from ._common import *
from ._attachment import *
from ._file import *
from ._location import *
from ._plan import *
from ._poll import *
from ._quick_reply import *
from ._sticker import *
from ._message import *

View File

@@ -0,0 +1,81 @@
import attr
from . import Image
from .._common import attrs_default
from .. import _util
from typing import Sequence
@attrs_default
class Attachment:
"""Represents a Facebook attachment."""
#: The attachment ID
id = attr.ib(None, type=str)
@attrs_default
class UnsentMessage(Attachment):
"""Represents an unsent message attachment."""
@attrs_default
class ShareAttachment(Attachment):
"""Represents a shared item (e.g. URL) attachment."""
#: ID of the author of the shared post
author = attr.ib(None, type=str)
#: Target URL
url = attr.ib(None, type=str)
#: Original URL if Facebook redirects the URL
original_url = attr.ib(None, type=str)
#: Title of the attachment
title = attr.ib(None, type=str)
#: Description of the attachment
description = attr.ib(None, type=str)
#: Name of the source
source = attr.ib(None, type=str)
#: The attached image
image = attr.ib(None, type=Image)
#: URL of the original image if Facebook uses ``safe_image``
original_image_url = attr.ib(None, type=str)
#: List of additional attachments
attachments = attr.ib(factory=list, type=Sequence[Attachment])
@classmethod
def _from_graphql(cls, data):
from . import _file
image = None
original_image_url = None
media = data.get("media")
if media and media.get("image"):
image = Image._from_uri(media["image"])
original_image_url = (
_util.get_url_parameter(image.url, "url")
if "/safe_image.php" in image.url
else image.url
)
url = data.get("url")
return cls(
id=data.get("deduplication_key"),
author=data["target"]["actors"][0]["id"]
if data["target"].get("actors")
else None,
url=url,
original_url=_util.get_url_parameter(url, "u")
if "/l.php?u=" in url
else url,
title=data["title_with_entities"].get("text"),
description=data["description"].get("text")
if data.get("description")
else None,
source=data["source"].get("text") if data.get("source") else None,
image=image,
original_image_url=original_image_url,
attachments=[
_file.graphql_to_subattachment(attachment)
for attachment in data.get("subattachments")
],
)

79
fbchat/_models/_common.py Normal file
View File

@@ -0,0 +1,79 @@
import attr
import datetime
import enum
from .._common import attrs_default
from .. import _util
class ThreadLocation(enum.Enum):
"""Used to specify where a thread is located (inbox, pending, archived, other)."""
INBOX = "INBOX"
PENDING = "PENDING"
ARCHIVED = "ARCHIVED"
OTHER = "OTHER"
@classmethod
def _parse(cls, value: str):
return cls(value.lstrip("FOLDER_"))
@attrs_default
class ActiveStatus:
#: Whether the user is active now
active = attr.ib(None, type=bool)
#: Datetime when the user was last active
last_active = attr.ib(None, type=datetime.datetime)
#: Whether the user is playing Messenger game now
in_game = attr.ib(None, type=bool)
@classmethod
def _from_orca_presence(cls, data):
# TODO: Handle `c` and `vc` keys (Probably some binary data)
return cls(
active=data["p"] in [2, 3],
last_active=_util.seconds_to_datetime(data["l"]) if "l" in data else None,
in_game=None,
)
@attrs_default
class Image:
#: URL to the image
url = attr.ib(type=str)
#: Width of the image
width = attr.ib(None, type=int)
#: Height of the image
height = attr.ib(None, type=int)
@classmethod
def _from_uri(cls, data):
return cls(
url=data["uri"],
width=int(data["width"]) if data.get("width") else None,
height=int(data["height"]) if data.get("height") else None,
)
@classmethod
def _from_url(cls, data):
return cls(
url=data["url"],
width=int(data["width"]) if data.get("width") else None,
height=int(data["height"]) if data.get("height") else None,
)
@classmethod
def _from_uri_or_none(cls, data):
if data is None:
return None
if data.get("uri") is None:
return None
return cls._from_uri(data)
@classmethod
def _from_url_or_none(cls, data):
if data is None:
return None
if data.get("url") is None:
return None
return cls._from_url(data)

195
fbchat/_models/_file.py Normal file
View File

@@ -0,0 +1,195 @@
import attr
import datetime
from . import Image, Attachment
from .._common import attrs_default
from .. import _util
from typing import Set
@attrs_default
class FileAttachment(Attachment):
"""Represents a file that has been sent as a Facebook attachment."""
#: URL where you can download the file
url = attr.ib(None, type=str)
#: Size of the file in bytes
size = attr.ib(None, type=int)
#: Name of the file
name = attr.ib(None, type=str)
#: Whether Facebook determines that this file may be harmful
is_malicious = attr.ib(None, type=bool)
@classmethod
def _from_graphql(cls, data, size=None):
return cls(
url=data.get("url"),
size=size,
name=data.get("filename"),
is_malicious=data.get("is_malicious"),
id=data.get("message_file_fbid"),
)
@attrs_default
class AudioAttachment(Attachment):
"""Represents an audio file that has been sent as a Facebook attachment."""
#: Name of the file
filename = attr.ib(None, type=str)
#: URL of the audio file
url = attr.ib(None, type=str)
#: Duration of the audio clip as a timedelta
duration = attr.ib(None, type=datetime.timedelta)
#: Audio type
audio_type = attr.ib(None, type=str)
@classmethod
def _from_graphql(cls, data):
return cls(
filename=data.get("filename"),
url=data.get("playable_url"),
duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")),
audio_type=data.get("audio_type"),
)
@attrs_default
class ImageAttachment(Attachment):
"""Represents an image that has been sent as a Facebook attachment.
To retrieve the full image URL, use: `Client.fetch_image_url`, and pass it the id of
the image attachment.
"""
#: The extension of the original image (e.g. ``png``)
original_extension = attr.ib(None, type=str)
#: Width of original image
width = attr.ib(None, converter=lambda x: None if x is None else int(x), type=int)
#: Height of original image
height = attr.ib(None, converter=lambda x: None if x is None else int(x), type=int)
#: Whether the image is animated
is_animated = attr.ib(None, type=bool)
#: A set, containing variously sized / various types of previews of the image
previews = attr.ib(factory=set, type=Set[Image])
@classmethod
def _from_graphql(cls, data):
previews = {
Image._from_uri_or_none(data.get("thumbnail")),
Image._from_uri_or_none(data.get("preview") or data.get("preview_image")),
Image._from_uri_or_none(data.get("large_preview")),
Image._from_uri_or_none(data.get("animated_image")),
}
return cls(
original_extension=data.get("original_extension")
or (data["filename"].split("-")[0] if data.get("filename") else None),
width=data.get("original_dimensions", {}).get("width"),
height=data.get("original_dimensions", {}).get("height"),
is_animated=data["__typename"] == "MessageAnimatedImage",
previews={p for p in previews if p},
id=data.get("legacy_attachment_id"),
)
@classmethod
def _from_list(cls, data):
previews = {
Image._from_uri_or_none(data["image"]),
Image._from_uri(data["image1"]),
Image._from_uri(data["image2"]),
}
return cls(
width=data["original_dimensions"].get("x"),
height=data["original_dimensions"].get("y"),
previews={p for p in previews if p},
id=data["legacy_attachment_id"],
)
@attrs_default
class VideoAttachment(Attachment):
"""Represents a video that has been sent as a Facebook attachment."""
#: Size of the original video in bytes
size = attr.ib(None, type=int)
#: Width of original video
width = attr.ib(None, type=int)
#: Height of original video
height = attr.ib(None, type=int)
#: Length of video as a timedelta
duration = attr.ib(None, type=datetime.timedelta)
#: URL to very compressed preview video
preview_url = attr.ib(None, type=str)
#: A set, containing variously sized previews of the video
previews = attr.ib(factory=set, type=Set[Image])
@classmethod
def _from_graphql(cls, data, size=None):
previews = {
Image._from_uri_or_none(data.get("chat_image")),
Image._from_uri_or_none(data.get("inbox_image")),
Image._from_uri_or_none(data.get("large_image")),
}
return cls(
size=size,
width=data.get("original_dimensions", {}).get("width"),
height=data.get("original_dimensions", {}).get("height"),
duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")),
preview_url=data.get("playable_url"),
previews={p for p in previews if p},
id=data.get("legacy_attachment_id"),
)
@classmethod
def _from_subattachment(cls, data):
media = data["media"]
image = Image._from_uri_or_none(media.get("image"))
return cls(
duration=_util.millis_to_timedelta(media.get("playable_duration_in_ms")),
preview_url=media.get("playable_url"),
previews={image} if image else {},
id=data["target"].get("video_id"),
)
@classmethod
def _from_list(cls, data):
previews = {
Image._from_uri(data["image"]),
Image._from_uri(data["image1"]),
Image._from_uri(data["image2"]),
}
return cls(
width=data["original_dimensions"].get("x"),
height=data["original_dimensions"].get("y"),
previews=previews,
id=data["legacy_attachment_id"],
)
def graphql_to_attachment(data, size=None):
_type = data["__typename"]
if _type in ["MessageImage", "MessageAnimatedImage"]:
return ImageAttachment._from_graphql(data)
elif _type == "MessageVideo":
return VideoAttachment._from_graphql(data, size=size)
elif _type == "MessageAudio":
return AudioAttachment._from_graphql(data)
elif _type == "MessageFile":
return FileAttachment._from_graphql(data, size=size)
return Attachment(id=data.get("legacy_attachment_id"))
def graphql_to_subattachment(data):
target = data.get("target")
type_ = target.get("__typename") if target else None
if type_ == "Video":
return VideoAttachment._from_subattachment(data)
return None

View File

@@ -0,0 +1,97 @@
import attr
from . import Image, Attachment
from .._common import attrs_default
from .. import _util, _exception
@attrs_default
class LocationAttachment(Attachment):
"""Represents a user location.
Latitude and longitude OR address is provided by Facebook.
"""
#: Latitude of the location
latitude = attr.ib(None, type=float)
#: Longitude of the location
longitude = attr.ib(None, type=float)
#: Image showing the map of the location
image = attr.ib(None, type=Image)
#: URL to Bing maps with the location
url = attr.ib(None, type=str)
# Address of the location
address = attr.ib(None, type=str)
@classmethod
def _from_graphql(cls, data):
url = data.get("url")
address = _util.get_url_parameter(_util.get_url_parameter(url, "u"), "where1")
if not address:
raise _exception.ParseError("Could not find location address", data=data)
try:
latitude, longitude = [float(x) for x in address.split(", ")]
address = None
except ValueError:
latitude, longitude = None, None
return cls(
id=int(data["deduplication_key"]),
latitude=latitude,
longitude=longitude,
image=Image._from_uri_or_none(data["media"].get("image"))
if data.get("media")
else None,
url=url,
address=address,
)
@attrs_default
class LiveLocationAttachment(LocationAttachment):
"""Represents a live user location."""
#: Name of the location
name = attr.ib(None)
#: Datetime when live location expires
expires_at = attr.ib(None)
#: True if live location is expired
is_expired = attr.ib(None)
@classmethod
def _from_pull(cls, data):
return cls(
id=data["id"],
latitude=data["coordinate"]["latitude"] / (10 ** 8)
if not data.get("stopReason")
else None,
longitude=data["coordinate"]["longitude"] / (10 ** 8)
if not data.get("stopReason")
else None,
name=data.get("locationTitle"),
expires_at=_util.millis_to_datetime(data["expirationTime"]),
is_expired=bool(data.get("stopReason")),
)
@classmethod
def _from_graphql(cls, data):
target = data["target"]
image = None
media = data.get("media")
if media and media.get("image"):
image = Image._from_uri(media["image"])
return cls(
id=int(target["live_location_id"]),
latitude=target["coordinate"]["latitude"]
if target.get("coordinate")
else None,
longitude=target["coordinate"]["longitude"]
if target.get("coordinate")
else None,
image=image,
url=data.get("url"),
name=data["title_with_entities"]["text"],
expires_at=_util.seconds_to_datetime(target.get("expiration_time")),
is_expired=target.get("is_expired"),
)

488
fbchat/_models/_message.py Normal file
View File

@@ -0,0 +1,488 @@
import attr
import datetime
import enum
from string import Formatter
from . import _attachment, _location, _file, _quick_reply, _sticker
from .._common import log, attrs_default
from .. import _exception, _util, _session, _threads
from typing import Optional, Mapping, Sequence
class EmojiSize(enum.Enum):
"""Used to specify the size of a sent emoji."""
LARGE = "369239383222810"
MEDIUM = "369239343222814"
SMALL = "369239263222822"
@classmethod
def _from_tags(cls, tags):
string_to_emojisize = {
"large": cls.LARGE,
"medium": cls.MEDIUM,
"small": cls.SMALL,
"l": cls.LARGE,
"m": cls.MEDIUM,
"s": cls.SMALL,
}
for tag in tags or ():
data = tag.split(":", 1)
if len(data) > 1 and data[0] == "hot_emoji_size":
return string_to_emojisize.get(data[1])
return None
@attrs_default
class Mention:
"""Represents a ``@mention``.
>>> fbchat.Mention(thread_id="1234", offset=5, length=2)
Mention(thread_id="1234", offset=5, length=2)
"""
#: The thread ID the mention is pointing at
thread_id = attr.ib(type=str)
#: The character where the mention starts
offset = attr.ib(type=int)
#: The length of the mention
length = attr.ib(type=int)
@classmethod
def _from_range(cls, data):
# TODO: Parse data["entity"]["__typename"]
return cls(
# Can be missing
thread_id=data["entity"].get("id"),
offset=data["offset"],
length=data["length"],
)
@classmethod
def _from_prng(cls, data):
return cls(thread_id=data["i"], offset=data["o"], length=data["l"])
def _to_send_data(self, i):
return {
"profile_xmd[{}][id]".format(i): self.thread_id,
"profile_xmd[{}][offset]".format(i): self.offset,
"profile_xmd[{}][length]".format(i): self.length,
"profile_xmd[{}][type]".format(i): "p",
}
# Exaustively searched for options by using the list in:
# https://unicode.org/emoji/charts/full-emoji-list.html
SENDABLE_REACTIONS = ("", "😍", "😆", "😮", "😢", "😠", "👍", "👎")
@attrs_default
class Message:
"""Represents a Facebook message.
Example:
>>> thread = fbchat.User(session=session, id="1234")
>>> message = fbchat.Message(thread=thread, id="mid.$XYZ")
"""
#: The thread that this message belongs to.
thread = attr.ib(type="_threads.ThreadABC")
#: The message ID.
id = attr.ib(converter=str, type=str)
@property
def session(self):
"""The session to use when making requests."""
return self.thread.session
@staticmethod
def _delete_many(session, message_ids):
data = {}
for i, id_ in enumerate(message_ids):
data["message_ids[{}]".format(i)] = id_
j = session._payload_post("/ajax/mercury/delete_messages.php?dpr=1", data)
def delete(self):
"""Delete the message (removes it only for the user).
If you want to delete multiple messages, please use `Client.delete_messages`.
Example:
>>> message.delete()
"""
self._delete_many(self.session, [self.id])
def unsend(self):
"""Unsend the message (removes it for everyone).
The message must to be sent by you, and less than 10 minutes ago.
Example:
>>> message.unsend()
"""
data = {"message_id": self.id}
j = self.session._payload_post("/messaging/unsend_message/?dpr=1", data)
def react(self, reaction: Optional[str]):
"""React to the message, or removes reaction.
Currently, you can use "", "😍", "😆", "😮", "😢", "😠", "👍" or "👎". It
should be possible to add support for more, but we haven't figured that out yet.
Args:
reaction: Reaction emoji to use, or if ``None``, removes reaction.
Example:
>>> message.react("😍")
"""
if reaction and reaction not in SENDABLE_REACTIONS:
raise ValueError(
"Invalid reaction! Please use one of: {}".format(SENDABLE_REACTIONS)
)
data = {
"action": "ADD_REACTION" if reaction else "REMOVE_REACTION",
"client_mutation_id": "1",
"actor_id": self.session.user.id,
"message_id": self.id,
"reaction": reaction,
}
data = {
"doc_id": 1491398900900362,
"variables": _util.json_minimal({"data": data}),
}
j = self.session._payload_post("/webgraphql/mutation", data)
_exception.handle_graphql_errors(j)
def fetch(self) -> "MessageData":
"""Fetch fresh `MessageData` object.
Example:
>>> message = message.fetch()
>>> message.text
"The message text"
"""
message_info = self.thread._forced_fetch(self.id).get("message")
return MessageData._from_graphql(self.thread, message_info)
@staticmethod
def format_mentions(text, *args, **kwargs):
"""Like `str.format`, but takes tuples with a thread id and text instead.
Return a tuple, with the formatted string and relevant mentions.
>>> Message.format_mentions("Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael"))
("Hey 'Peter'! My name is Michael", [Mention(thread_id=1234, offset=4, length=7), Mention(thread_id=4321, offset=24, length=7)])
>>> Message.format_mentions("Hey {p}! My name is {}", ("1234", "Michael"), p=("4321", "Peter"))
('Hey Peter! My name is Michael', [Mention(thread_id=4321, offset=4, length=5), Mention(thread_id=1234, offset=22, length=7)])
"""
result = ""
mentions = list()
offset = 0
f = Formatter()
field_names = [field_name[1] for field_name in f.parse(text)]
automatic = "" in field_names
i = 0
for (literal_text, field_name, format_spec, conversion) in f.parse(text):
offset += len(literal_text)
result += literal_text
if field_name is None:
continue
if field_name == "":
field_name = str(i)
i += 1
elif automatic and field_name.isdigit():
raise ValueError(
"cannot switch from automatic field numbering to manual field specification"
)
thread_id, name = f.get_field(field_name, args, kwargs)[0]
if format_spec:
name = f.format_field(name, format_spec)
if conversion:
name = f.convert_field(name, conversion)
result += name
mentions.append(
Mention(thread_id=thread_id, offset=offset, length=len(name))
)
offset += len(name)
return result, mentions
@attrs_default
class MessageSnippet(Message):
"""Represents data in a Facebook message snippet.
Inherits `Message`.
"""
#: ID of the sender
author = attr.ib(type=str)
#: Datetime of when the message was sent
created_at = attr.ib(type=datetime.datetime)
#: The actual message
text = attr.ib(type=str)
#: A dict with offsets, mapped to the matched text
matched_keywords = attr.ib(type=Mapping[int, str])
@classmethod
def _parse(cls, thread, data):
return cls(
thread=thread,
id=data["message_id"],
author=data["author"].rstrip("fbid:"),
created_at=_util.millis_to_datetime(data["timestamp"]),
text=data["body"],
matched_keywords={int(k): v for k, v in data["matched_keywords"].items()},
)
@attrs_default
class MessageData(Message):
"""Represents data in a Facebook message.
Inherits `Message`.
"""
#: ID of the sender
author = attr.ib(type=str)
#: Datetime of when the message was sent
created_at = attr.ib(type=datetime.datetime)
#: The actual message
text = attr.ib(None, type=str)
#: A list of `Mention` objects
mentions = attr.ib(factory=list, type=Sequence[Mention])
#: Size of a sent emoji
emoji_size = attr.ib(None, type=EmojiSize)
#: Whether the message is read
is_read = attr.ib(None, type=bool)
#: A list of people IDs who read the message, works only with `Client.fetch_thread_messages`
read_by = attr.ib(factory=list, type=bool)
#: A dictionary with user's IDs as keys, and their reaction as values
reactions = attr.ib(factory=dict, type=Mapping[str, str])
#: A `Sticker`
sticker = attr.ib(None, type=_sticker.Sticker)
#: A list of attachments
attachments = attr.ib(factory=list, type=Sequence[_attachment.Attachment])
#: A list of `QuickReply`
quick_replies = attr.ib(factory=list, type=Sequence[_quick_reply.QuickReply])
#: Whether the message is unsent (deleted for everyone)
unsent = attr.ib(False, type=bool)
#: Message ID you want to reply to
reply_to_id = attr.ib(None, type=str)
#: Replied message
replied_to = attr.ib(None, type="MessageData")
#: Whether the message was forwarded
forwarded = attr.ib(False, type=bool)
@staticmethod
def _get_forwarded_from_tags(tags):
if tags is None:
return False
return any(map(lambda tag: "forward" in tag or "copy" in tag, tags))
@staticmethod
def _parse_quick_replies(data):
if data:
data = _util.parse_json(data).get("quick_replies")
if isinstance(data, list):
return [_quick_reply.graphql_to_quick_reply(q) for q in data]
elif isinstance(data, dict):
return [_quick_reply.graphql_to_quick_reply(data, is_response=True)]
return []
@classmethod
def _from_graphql(cls, thread, data, read_receipts=None):
if data.get("message_sender") is None:
data["message_sender"] = {}
if data.get("message") is None:
data["message"] = {}
tags = data.get("tags_list")
created_at = _util.millis_to_datetime(int(data.get("timestamp_precise")))
attachments = [
_file.graphql_to_attachment(attachment)
for attachment in data.get("blob_attachments") or ()
]
unsent = False
if data.get("extensible_attachment") is not None:
attachment = graphql_to_extensible_attachment(data["extensible_attachment"])
if isinstance(attachment, _attachment.UnsentMessage):
unsent = True
elif attachment:
attachments.append(attachment)
replied_to = None
if data.get("replied_to_message") and data["replied_to_message"]["message"]:
# data["replied_to_message"]["message"] is None if the message is deleted
replied_to = cls._from_graphql(
thread, data["replied_to_message"]["message"]
)
return cls(
thread=thread,
id=str(data["message_id"]),
author=str(data["message_sender"]["id"]),
created_at=created_at,
text=data["message"].get("text"),
mentions=[
Mention._from_range(m) for m in data["message"].get("ranges") or ()
],
emoji_size=EmojiSize._from_tags(tags),
is_read=not data["unread"] if data.get("unread") is not None else None,
read_by=[
receipt["actor"]["id"]
for receipt in read_receipts or ()
if _util.millis_to_datetime(int(receipt["watermark"])) >= created_at
],
reactions={
str(r["user"]["id"]): r["reaction"] for r in data["message_reactions"]
},
sticker=_sticker.Sticker._from_graphql(data.get("sticker")),
attachments=attachments,
quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")),
unsent=unsent,
reply_to_id=replied_to.id if replied_to else None,
replied_to=replied_to,
forwarded=cls._get_forwarded_from_tags(tags),
)
@classmethod
def _from_reply(cls, thread, data):
tags = data["messageMetadata"].get("tags")
metadata = data.get("messageMetadata", {})
attachments = []
unsent = False
sticker = None
for attachment in data.get("attachments") or ():
attachment = _util.parse_json(attachment["mercuryJSON"])
if attachment.get("blob_attachment"):
attachments.append(
_file.graphql_to_attachment(attachment["blob_attachment"])
)
if attachment.get("extensible_attachment"):
extensible_attachment = graphql_to_extensible_attachment(
attachment["extensible_attachment"]
)
if isinstance(extensible_attachment, _attachment.UnsentMessage):
unsent = True
else:
attachments.append(extensible_attachment)
if attachment.get("sticker_attachment"):
sticker = _sticker.Sticker._from_graphql(
attachment["sticker_attachment"]
)
return cls(
thread=thread,
id=metadata.get("messageId"),
author=str(metadata["actorFbId"]),
created_at=_util.millis_to_datetime(metadata["timestamp"]),
text=data.get("body"),
mentions=[
Mention._from_prng(m)
for m in _util.parse_json(data.get("data", {}).get("prng", "[]"))
],
emoji_size=EmojiSize._from_tags(tags),
sticker=sticker,
attachments=attachments,
quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")),
unsent=unsent,
reply_to_id=data["messageReply"]["replyToMessageId"]["id"]
if "messageReply" in data
else None,
forwarded=cls._get_forwarded_from_tags(tags),
)
@classmethod
def _from_pull(cls, thread, data, author, created_at):
metadata = data["messageMetadata"]
tags = metadata.get("tags")
mentions = []
if data.get("data") and data["data"].get("prng"):
try:
mentions = [
Mention._from_prng(m)
for m in _util.parse_json(data["data"]["prng"])
]
except Exception:
log.exception("An exception occured while reading attachments")
attachments = []
unsent = False
sticker = None
try:
for a in data.get("attachments") or ():
mercury = a["mercury"]
if mercury.get("blob_attachment"):
image_metadata = a.get("imageMetadata", {})
attach_type = mercury["blob_attachment"]["__typename"]
attachment = _file.graphql_to_attachment(
mercury["blob_attachment"], a.get("fileSize")
)
attachments.append(attachment)
elif mercury.get("sticker_attachment"):
sticker = _sticker.Sticker._from_graphql(
mercury["sticker_attachment"]
)
elif mercury.get("extensible_attachment"):
attachment = graphql_to_extensible_attachment(
mercury["extensible_attachment"]
)
if isinstance(attachment, _attachment.UnsentMessage):
unsent = True
elif attachment:
attachments.append(attachment)
except Exception:
log.exception(
"An exception occured while reading attachments: {}".format(
data["attachments"]
)
)
return cls(
thread=thread,
id=metadata["messageId"],
author=author,
created_at=created_at,
text=data.get("body"),
mentions=mentions,
emoji_size=EmojiSize._from_tags(tags),
sticker=sticker,
attachments=attachments,
unsent=unsent,
forwarded=cls._get_forwarded_from_tags(tags),
)
def graphql_to_extensible_attachment(data):
story = data.get("story_attachment")
if not story:
return None
target = story.get("target")
if not target:
return _attachment.UnsentMessage(id=data.get("legacy_attachment_id"))
_type = target["__typename"]
if _type == "MessageLocation":
return _location.LocationAttachment._from_graphql(story)
elif _type == "MessageLiveLocation":
return _location.LiveLocationAttachment._from_graphql(story)
elif _type in ["ExternalUrl", "Story"]:
return _attachment.ShareAttachment._from_graphql(story)
return None

212
fbchat/_models/_plan.py Normal file
View File

@@ -0,0 +1,212 @@
import attr
import datetime
import enum
from .._common import attrs_default
from .. import _exception, _util, _session
from typing import Mapping, Sequence
class GuestStatus(enum.Enum):
INVITED = 1
GOING = 2
DECLINED = 3
ACONTEXT = {
"action_history": [
{"surface": "messenger_chat_tab", "mechanism": "messenger_composer"}
]
}
@attrs_default
class Plan:
"""Base model for plans.
Example:
>>> plan = fbchat.Plan(session=session, id="1234")
"""
#: The session to use when making requests.
session = attr.ib(type=_session.Session)
#: The plan's unique identifier.
id = attr.ib(converter=str, type=str)
def fetch(self) -> "PlanData":
"""Fetch fresh `PlanData` object.
Example:
>>> plan = plan.fetch()
>>> plan.title
"A plan"
"""
data = {"event_reminder_id": self.id}
j = self.session._payload_post("/ajax/eventreminder", data)
return PlanData._from_fetch(self.session, j)
@classmethod
def _create(
cls,
thread,
name: str,
at: datetime.datetime,
location_name: str = None,
location_id: str = None,
):
data = {
"event_type": "EVENT",
"event_time": _util.datetime_to_seconds(at),
"title": name,
"thread_id": thread.id,
"location_id": location_id or "",
"location_name": location_name or "",
"acontext": ACONTEXT,
}
j = thread.session._payload_post("/ajax/eventreminder/create", data)
if "error" in j:
raise _exception.ExternalError("Failed creating plan", j["error"])
def edit(
self,
name: str,
at: datetime.datetime,
location_name: str = None,
location_id: str = None,
):
"""Edit the plan.
# TODO: Arguments
"""
data = {
"event_reminder_id": self.id,
"delete": "false",
"date": _util.datetime_to_seconds(at),
"location_name": location_name or "",
"location_id": location_id or "",
"title": name,
"acontext": ACONTEXT,
}
j = self.session._payload_post("/ajax/eventreminder/submit", data)
def delete(self):
"""Delete the plan.
Example:
>>> plan.delete()
"""
data = {"event_reminder_id": self.id, "delete": "true", "acontext": ACONTEXT}
j = self.session._payload_post("/ajax/eventreminder/submit", data)
def _change_participation(self):
data = {
"event_reminder_id": self.id,
"guest_state": "GOING" if take_part else "DECLINED",
"acontext": ACONTEXT,
}
j = self.session._payload_post("/ajax/eventreminder/rsvp", data)
def participate(self):
"""Set yourself as GOING/participating to the plan.
Example:
>>> plan.participate()
"""
return self._change_participation(True)
def decline(self):
"""Set yourself as having DECLINED the plan.
Example:
>>> plan.decline()
"""
return self._change_participation(False)
@attrs_default
class PlanData(Plan):
"""Represents data about a plan."""
#: Plan time, only precise down to the minute
time = attr.ib(type=datetime.datetime)
#: Plan title
title = attr.ib(type=str)
#: Plan location name
location = attr.ib(None, converter=lambda x: x or "", type=str)
#: Plan location ID
location_id = attr.ib(None, converter=lambda x: x or "", type=str)
#: ID of the plan creator
author_id = attr.ib(None, type=str)
#: `User` ids mapped to their `GuestStatus`
guests = attr.ib(None, type=Mapping[str, GuestStatus])
@property
def going(self) -> Sequence[str]:
"""List of the `User` IDs who will take part in the plan."""
return [
id_
for id_, status in (self.guests or {}).items()
if status is GuestStatus.GOING
]
@property
def declined(self) -> Sequence[str]:
"""List of the `User` IDs who won't take part in the plan."""
return [
id_
for id_, status in (self.guests or {}).items()
if status is GuestStatus.DECLINED
]
@property
def invited(self) -> Sequence[str]:
"""List of the `User` IDs who are invited to the plan."""
return [
id_
for id_, status in (self.guests or {}).items()
if status is GuestStatus.INVITED
]
@classmethod
def _from_pull(cls, session, data):
return cls(
session=session,
id=data.get("event_id"),
time=_util.seconds_to_datetime(int(data.get("event_time"))),
title=data.get("event_title"),
location=data.get("event_location_name"),
location_id=data.get("event_location_id"),
author_id=data.get("event_creator_id"),
guests={
x["node"]["id"]: GuestStatus[x["guest_list_state"]]
for x in _util.parse_json(data["guest_state_list"])
},
)
@classmethod
def _from_fetch(cls, session, data):
return cls(
session=session,
id=data.get("oid"),
time=_util.seconds_to_datetime(data.get("event_time")),
title=data.get("title"),
location=data.get("location_name"),
location_id=str(data["location_id"]) if data.get("location_id") else None,
author_id=data.get("creator_id"),
guests={id_: GuestStatus[s] for id_, s in data["event_members"].items()},
)
@classmethod
def _from_graphql(cls, session, data):
return cls(
session=session,
id=data.get("id"),
time=_util.seconds_to_datetime(data.get("time")),
title=data.get("event_title"),
location=data.get("location_name"),
author_id=data["lightweight_event_creator"].get("id"),
guests={
x["node"]["id"]: GuestStatus[x["guest_list_state"]]
for x in data["event_reminder_members"]["edges"]
},
)

115
fbchat/_models/_poll.py Normal file
View File

@@ -0,0 +1,115 @@
import attr
from .._common import attrs_default
from .. import _exception, _session
from typing import Iterable, Sequence
@attrs_default
class PollOption:
"""Represents a poll option."""
#: ID of the poll option
id = attr.ib(converter=str, type=str)
#: Text of the poll option
text = attr.ib(type=str)
#: Whether vote when creating or client voted
vote = attr.ib(type=bool)
#: ID of the users who voted for this poll option
voters = attr.ib(type=Sequence[str])
#: Votes count
votes_count = attr.ib(type=int)
@classmethod
def _from_graphql(cls, data):
if data.get("viewer_has_voted") is None:
vote = False
elif isinstance(data["viewer_has_voted"], bool):
vote = data["viewer_has_voted"]
else:
vote = data["viewer_has_voted"] == "true"
return cls(
id=int(data["id"]),
text=data.get("text"),
vote=vote,
voters=(
[m["node"]["id"] for m in data["voters"]["edges"]]
if isinstance(data.get("voters"), dict)
else data["voters"]
),
votes_count=(
data["voters"]["count"]
if isinstance(data.get("voters"), dict)
else data["total_count"]
),
)
@attrs_default
class Poll:
"""Represents a poll."""
#: ID of the poll
session = attr.ib(type=_session.Session)
#: ID of the poll
id = attr.ib(converter=str, type=str)
#: The poll's question
question = attr.ib(type=str)
#: The poll's top few options. The full list can be fetched with `fetch_options`
options = attr.ib(type=Sequence[PollOption])
#: Options count
options_count = attr.ib(type=int)
@classmethod
def _from_graphql(cls, session, data):
return cls(
session=session,
id=data["id"],
question=data["title"] if data.get("title") else data["text"],
options=[PollOption._from_graphql(m) for m in data["options"]],
options_count=data["total_count"],
)
def fetch_options(self) -> Sequence[PollOption]:
"""Fetch all `PollOption` objects on the poll.
The result is ordered with options with the most votes first.
Example:
>>> options = poll.fetch_options()
>>> options[0].text
"An option"
"""
data = {"question_id": self.id}
j = self.session._payload_post("/ajax/mercury/get_poll_options", data)
return [PollOption._from_graphql(m) for m in j]
def set_votes(self, option_ids: Iterable[str], new_options: Iterable[str] = None):
"""Update the user's poll vote.
Args:
option_ids: Option ids to vote for / keep voting for
new_options: New options to add
Example:
>>> options = poll.fetch_options()
>>> # Add option
>>> poll.set_votes([o.id for o in options], new_options=["New option"])
>>> # Remove vote from option
>>> poll.set_votes([o.id for o in options if o.text != "Option 1"])
"""
data = {"question_id": self.id}
for i, option_id in enumerate(option_ids or ()):
data["selected_options[{}]".format(i)] = option_id
for i, option_text in enumerate(new_options or ()):
data["new_options[{}]".format(i)] = option_text
j = self.session._payload_post(
"/messaging/group_polling/update_vote/?dpr=1", data
)
if j.get("status") != "success":
raise _exception.ExternalError(
"Failed updating poll vote: {}".format(j.get("errorTitle")),
j.get("errorMessage"),
)

View File

@@ -0,0 +1,82 @@
import attr
from . import Attachment
from .._common import attrs_default
from typing import Any
@attrs_default
class QuickReply:
"""Represents a quick reply."""
#: Payload of the quick reply
payload = attr.ib(None, type=Any)
#: External payload for responses
external_payload = attr.ib(None, type=Any)
#: Additional data
data = attr.ib(None, type=Any)
#: Whether it's a response for a quick reply
is_response = attr.ib(False, type=bool)
@attrs_default
class QuickReplyText(QuickReply):
"""Represents a text quick reply."""
#: Title of the quick reply
title = attr.ib(None, type=str)
#: URL of the quick reply image (optional)
image_url = attr.ib(None, type=str)
#: Type of the quick reply
_type = "text"
@attrs_default
class QuickReplyLocation(QuickReply):
"""Represents a location quick reply (Doesn't work on mobile)."""
#: Type of the quick reply
_type = "location"
@attrs_default
class QuickReplyPhoneNumber(QuickReply):
"""Represents a phone number quick reply (Doesn't work on mobile)."""
#: URL of the quick reply image (optional)
image_url = attr.ib(None, type=str)
#: Type of the quick reply
_type = "user_phone_number"
@attrs_default
class QuickReplyEmail(QuickReply):
"""Represents an email quick reply (Doesn't work on mobile)."""
#: URL of the quick reply image (optional)
image_url = attr.ib(None, type=str)
#: Type of the quick reply
_type = "user_email"
def graphql_to_quick_reply(q, is_response=False):
data = dict()
_type = q.get("content_type").lower()
if q.get("payload"):
data["payload"] = q["payload"]
if q.get("data"):
data["data"] = q["data"]
if q.get("image_url") and _type is not QuickReplyLocation._type:
data["image_url"] = q["image_url"]
data["is_response"] = is_response
if _type == QuickReplyText._type:
if q.get("title") is not None:
data["title"] = q["title"]
rtn = QuickReplyText(**data)
elif _type == QuickReplyLocation._type:
rtn = QuickReplyLocation(**data)
elif _type == QuickReplyPhoneNumber._type:
rtn = QuickReplyPhoneNumber(**data)
elif _type == QuickReplyEmail._type:
rtn = QuickReplyEmail(**data)
return rtn

View File

@@ -0,0 +1,55 @@
import attr
from . import Image, Attachment
from .._common import attrs_default
@attrs_default
class Sticker(Attachment):
"""Represents a Facebook sticker that has been sent to a thread as an attachment."""
#: The sticker-pack's ID
pack = attr.ib(None, type=str)
#: Whether the sticker is animated
is_animated = attr.ib(False, type=bool)
# If the sticker is animated, the following should be present
#: URL to a medium spritemap
medium_sprite_image = attr.ib(None, type=str)
#: URL to a large spritemap
large_sprite_image = attr.ib(None, type=str)
#: The amount of frames present in the spritemap pr. row
frames_per_row = attr.ib(None, type=int)
#: The amount of frames present in the spritemap pr. column
frames_per_col = attr.ib(None, type=int)
#: The total amount of frames in the spritemap
frame_count = attr.ib(None, type=int)
#: The frame rate the spritemap is intended to be played in
frame_rate = attr.ib(None, type=int)
#: The sticker's image
image = attr.ib(None, type=Image)
#: The sticker's label/name
label = attr.ib(None, type=str)
@classmethod
def _from_graphql(cls, data):
if not data:
return None
return cls(
id=data["id"],
pack=data["pack"].get("id") if data.get("pack") else None,
is_animated=bool(data.get("sprite_image")),
medium_sprite_image=data["sprite_image"].get("uri")
if data.get("sprite_image")
else None,
large_sprite_image=data["sprite_image_2x"].get("uri")
if data.get("sprite_image_2x")
else None,
frames_per_row=data.get("frames_per_row"),
frames_per_col=data.get("frames_per_column"),
frame_count=data.get("frame_count"),
frame_rate=data.get("frame_rate"),
image=Image._from_url_or_none(data),
label=data["label"] if data.get("label") else None,
)

427
fbchat/_session.py Normal file
View File

@@ -0,0 +1,427 @@
import attr
import bs4
import datetime
import re
import requests
import random
import urllib.parse
from ._common import log, kw_only
from . import _graphql, _util, _exception
from typing import Optional, Tuple, Mapping, Callable
FB_DTSG_REGEX = re.compile(r'name="fb_dtsg" value="(.*?)"')
def base36encode(number: int) -> str:
"""Convert from Base10 to Base36."""
# Taken from https://en.wikipedia.org/wiki/Base36#Python_implementation
chars = "0123456789abcdefghijklmnopqrstuvwxyz"
sign = "-" if number < 0 else ""
number = abs(number)
result = ""
while number > 0:
number, remainder = divmod(number, 36)
result = chars[remainder] + result
return sign + result
def prefix_url(url: str) -> str:
if url.startswith("/"):
return "https://www.facebook.com" + url
return url
def generate_message_id(now: datetime.datetime, client_id: str) -> str:
k = _util.datetime_to_millis(now)
l = int(random.random() * 4294967295)
return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id)
def get_user_id(session: requests.Session) -> str:
# TODO: Optimize this `.get_dict()` call!
cookies = session.cookies.get_dict()
rtn = cookies.get("c_user")
if rtn is None:
raise _exception.ParseError("Could not find user id", data=cookies)
return str(rtn)
def find_input_fields(html: str):
return bs4.BeautifulSoup(html, "html.parser", parse_only=bs4.SoupStrainer("input"))
def session_factory() -> requests.Session:
session = requests.session()
session.headers["Referer"] = "https://www.facebook.com"
# TODO: Deprecate setting the user agent manually
session.headers["User-Agent"] = random.choice(_util.USER_AGENTS)
return session
def client_id_factory() -> str:
return hex(int(random.random() * 2 ** 31))[2:]
def is_home(url: str) -> bool:
parts = urllib.parse.urlparse(url)
# Check the urls `/home.php` and `/`
return "home" in parts.path or "/" == parts.path
def _2fa_helper(session: requests.Session, code: int, r):
soup = find_input_fields(r.text)
data = dict()
url = "https://m.facebook.com/login/checkpoint/"
data["approvals_code"] = str(code)
data["fb_dtsg"] = soup.find("input", {"name": "fb_dtsg"})["value"]
data["nh"] = soup.find("input", {"name": "nh"})["value"]
data["submit[Submit Code]"] = "Submit Code"
data["codes_submitted"] = "0"
log.info("Submitting 2FA code.")
r = session.post(url, data=data)
if is_home(r.url):
return r
del data["approvals_code"]
del data["submit[Submit Code]"]
del data["codes_submitted"]
data["name_action_selected"] = "save_device"
data["submit[Continue]"] = "Continue"
log.info("Saving browser.")
# At this stage, we have dtsg, nh, name_action_selected, submit[Continue]
r = session.post(url, data=data)
if is_home(r.url):
return r
del data["name_action_selected"]
log.info("Starting Facebook checkup flow.")
# At this stage, we have dtsg, nh, submit[Continue]
r = session.post(url, data=data)
if is_home(r.url):
return r
del data["submit[Continue]"]
data["submit[This was me]"] = "This Was Me"
log.info("Verifying login attempt.")
# At this stage, we have dtsg, nh, submit[This was me]
r = session.post(url, data=data)
if is_home(r.url):
return r
del data["submit[This was me]"]
data["submit[Continue]"] = "Continue"
data["name_action_selected"] = "save_device"
log.info("Saving device again.")
# At this stage, we have dtsg, nh, submit[Continue], name_action_selected
r = session.post(url, data=data)
return r
def get_error_data(html: str, url: str) -> Tuple[Optional[int], Optional[str]]:
"""Get error code and message from a request."""
code = None
try:
code = int(_util.get_url_parameter(url, "e"))
except (TypeError, ValueError):
pass
soup = bs4.BeautifulSoup(
html, "html.parser", parse_only=bs4.SoupStrainer("div", id="login_error")
)
return code, soup.get_text() or None
@attr.s(slots=True, kw_only=kw_only, repr=False, eq=False)
class Session:
"""Stores and manages state required for most Facebook requests.
This is the main class, which is used to login to Facebook.
"""
_user_id = attr.ib(type=str)
_fb_dtsg = attr.ib(type=str)
_revision = attr.ib(type=int)
_session = attr.ib(factory=session_factory, type=requests.Session)
_counter = attr.ib(0, type=int)
_client_id = attr.ib(factory=client_id_factory, type=str)
_logout_h = attr.ib(None, type=str)
@property
def user(self):
"""The logged in user."""
from . import _threads
# TODO: Consider caching the result
return _threads.User(session=self, id=self._user_id)
def __repr__(self) -> str:
# An alternative repr, to illustrate that you can't create the class directly
return "<fbchat.Session user_id={}>".format(self._user_id)
def _get_params(self):
self._counter += 1 # TODO: Make this operation atomic / thread-safe
return {
"__a": 1,
"__req": base36encode(self._counter),
"__rev": self._revision,
"fb_dtsg": self._fb_dtsg,
}
@classmethod
def login(
cls, email: str, password: str, on_2fa_callback: Callable[[], int] = None
):
"""Login the user, using ``email`` and ``password``.
Args:
email: Facebook ``email``, ``id`` or ``phone number``
password: Facebook account password
on_2fa_callback: Function that will be called, in case a 2FA code is needed.
This should return the requested 2FA code.
Example:
>>> import getpass
>>> import fbchat
>>> session = fbchat.Session.login("<email or phone>", getpass.getpass())
>>> session.user.id
"1234"
"""
session = session_factory()
try:
r = session.get("https://m.facebook.com/")
except requests.RequestException as e:
_exception.handle_requests_error(e)
soup = find_input_fields(r.text)
data = dict(
(elem["name"], elem["value"])
for elem in soup
if elem.has_attr("value") and elem.has_attr("name")
)
data["email"] = email
data["pass"] = password
data["login"] = "Log In"
try:
url = "https://m.facebook.com/login.php?login_attempt=1"
r = session.post(url, data=data)
except requests.RequestException as e:
_exception.handle_requests_error(e)
# Usually, 'Checkpoint' will refer to 2FA
if "checkpoint" in r.url and ('id="approvals_code"' in r.text.lower()):
if not on_2fa_callback:
raise ValueError(
"2FA code required, please add `on_2fa_callback` to .login"
)
code = on_2fa_callback()
try:
r = _2fa_helper(session, code, r)
except requests.RequestException as e:
_exception.handle_requests_error(e)
# Sometimes Facebook tries to show the user a "Save Device" dialog
if "save-device" in r.url:
try:
r = session.get("https://m.facebook.com/login/save-device/cancel/")
except requests.RequestException as e:
_exception.handle_requests_error(e)
if is_home(r.url):
return cls._from_session(session=session)
else:
code, msg = get_error_data(r.text, r.url)
raise _exception.ExternalError(
"Login failed at url {!r}".format(r.url), msg, code=code
)
def is_logged_in(self) -> bool:
"""Send a request to Facebook to check the login status.
Returns:
Whether the user is still logged in
Example:
>>> assert session.is_logged_in()
"""
# Send a request to the login url, to see if we're directed to the home page
url = "https://m.facebook.com/login.php?login_attempt=1"
try:
r = self._session.get(url, allow_redirects=False)
except requests.RequestException as e:
_exception.handle_requests_error(e)
return "Location" in r.headers and is_home(r.headers["Location"])
def logout(self) -> None:
"""Safely log out the user.
The session object must not be used after this action has been performed!
Example:
>>> session.logout()
"""
logout_h = self._logout_h
if not logout_h:
url = prefix_url("/bluebar/modern_settings_menu/")
try:
h_r = self._session.post(url, data={"pmid": "4"})
except requests.RequestException as e:
_exception.handle_requests_error(e)
logout_h = re.search(r'name=\\"h\\" value=\\"(.*?)\\"', h_r.text).group(1)
url = prefix_url("/logout.php")
try:
r = self._session.get(url, params={"ref": "mb", "h": logout_h})
except requests.RequestException as e:
_exception.handle_requests_error(e)
_exception.handle_http_error(r.status_code)
@classmethod
def _from_session(cls, session):
# TODO: Automatically set user_id when the cookie changes in the session
user_id = get_user_id(session)
try:
r = session.get(prefix_url("/"))
except requests.RequestException as e:
_exception.handle_requests_error(e)
soup = find_input_fields(r.text)
fb_dtsg_element = soup.find("input", {"name": "fb_dtsg"})
if fb_dtsg_element:
fb_dtsg = fb_dtsg_element["value"]
else:
# Fall back to searching with a regex
res = FB_DTSG_REGEX.search(r.text)
if not res:
raise _exception.NotLoggedIn("Could not find fb_dtsg")
fb_dtsg = res.group(1)
revision = int(r.text.split('"client_revision":', 1)[1].split(",", 1)[0])
logout_h_element = soup.find("input", {"name": "h"})
logout_h = logout_h_element["value"] if logout_h_element else None
return cls(
user_id=user_id,
fb_dtsg=fb_dtsg,
revision=revision,
session=session,
logout_h=logout_h,
)
def get_cookies(self) -> Mapping[str, str]:
"""Retrieve session cookies, that can later be used in `from_cookies`.
Returns:
A dictionary containing session cookies
Example:
>>> cookies = session.get_cookies()
"""
return self._session.cookies.get_dict()
@classmethod
def from_cookies(cls, cookies: Mapping[str, str]):
"""Load a session from session cookies.
Args:
cookies: A dictionary containing session cookies
Example:
>>> cookies = session.get_cookies()
>>> # Store cookies somewhere, and then subsequently
>>> session = fbchat.Session.from_cookies(cookies)
"""
session = session_factory()
session.cookies = requests.cookies.merge_cookies(session.cookies, cookies)
return cls._from_session(session=session)
def _post(self, url, data, files=None, as_graphql=False):
data.update(self._get_params())
try:
r = self._session.post(prefix_url(url), data=data, files=files)
except requests.RequestException as e:
_exception.handle_requests_error(e)
# Facebook's encoding is always UTF-8
r.encoding = "utf-8"
_exception.handle_http_error(r.status_code)
if r.text is None or len(r.text) == 0:
raise _exception.HTTPError("Error when sending request: Got empty response")
if as_graphql:
return _graphql.response_to_json(r.text)
else:
text = _util.strip_json_cruft(r.text)
j = _util.parse_json(text)
log.debug(j)
return j
def _payload_post(self, url, data, files=None):
j = self._post(url, data, files=files)
_exception.handle_payload_error(j)
# update fb_dtsg token if received in response
if "jsmods" in j:
define = _util.get_jsmods_define(j["jsmods"]["define"])
if "DTSGInitData" in define:
self._fb_dtsg = define["DTSGInitData"]["token"]
elif "DTSGInitialData" in define:
self._fb_dtsg = define["DTSGInitialData"]["token"]
try:
return j["payload"]
except (KeyError, TypeError) as e:
raise _exception.ParseError("Missing payload", data=j) from e
def _graphql_requests(self, *queries):
# TODO: Explain usage of GraphQL, probably in the docs
# Perhaps provide this API as public?
data = {
"method": "GET",
"response_format": "json",
"queries": _graphql.queries_to_json(*queries),
}
return self._post("/api/graphqlbatch/", data, as_graphql=True)
def _do_send_request(self, data):
now = datetime.datetime.utcnow()
offline_threading_id = _util.generate_offline_threading_id()
data["client"] = "mercury"
data["author"] = "fbid:{}".format(self._user_id)
data["timestamp"] = _util.datetime_to_millis(now)
data["source"] = "source:chat:web"
data["offline_threading_id"] = offline_threading_id
data["message_id"] = offline_threading_id
data["threading_id"] = generate_message_id(now, self._client_id)
data["ephemeral_ttl_mode:"] = "0"
j = self._post("/messaging/send/", data)
_exception.handle_payload_error(j)
try:
message_ids = [
(action["message_id"], action["thread_fbid"])
for action in j["payload"]["actions"]
if "message_id" in action
]
if len(message_ids) != 1:
log.warning("Got multiple message ids' back: {}".format(message_ids))
return message_ids[0]
except (KeyError, IndexError, TypeError) as e:
raise _exception.ParseError("No message IDs could be found", data=j) from e

View File

@@ -0,0 +1,4 @@
from ._abc import *
from ._group import *
from ._user import *
from ._page import *

816
fbchat/_threads/_abc.py Normal file
View File

@@ -0,0 +1,816 @@
import abc
import attr
import collections
import datetime
from .._common import log, attrs_default
from .. import _util, _exception, _session, _graphql, _models
from typing import MutableMapping, Mapping, Any, Iterable, Tuple, Optional
DEFAULT_COLOR = "#0084ff"
SETABLE_COLORS = (
DEFAULT_COLOR,
"#44bec7",
"#ffc300",
"#fa3c4c",
"#d696bb",
"#6699cc",
"#13cf13",
"#ff7e29",
"#e68585",
"#7646ff",
"#20cef5",
"#67b868",
"#d4a88c",
"#ff5ca1",
"#a695c7",
"#ff7ca8",
"#1adb5b",
"#f01d6a",
"#ff9c19",
"#0edcde",
)
class ThreadABC(metaclass=abc.ABCMeta):
"""Implemented by thread-like classes.
This is private to implement.
"""
@property
@abc.abstractmethod
def session(self) -> _session.Session:
"""The session to use when making requests."""
raise NotImplementedError
@property
@abc.abstractmethod
def id(self) -> str:
"""The unique identifier of the thread."""
raise NotImplementedError
@abc.abstractmethod
def _to_send_data(self) -> MutableMapping[str, str]:
raise NotImplementedError
# Note:
# You can go out of Facebook's spec with `self.session._do_send_request`!
#
# A few examples:
# - You can send a sticker and an emoji at the same time
# - You can wave, send a sticker and text at the same time
# - You can reply to a message with a sticker
#
# We won't support those use cases, it'll make for a confusing API!
# If we absolutely need to in the future, we can always add extra functionality
@abc.abstractmethod
def _copy(self) -> "ThreadABC":
"""It may or may not be a good idea to attach the current thread to new objects.
So for now, we use this method to create a new thread.
This should return the minimal representation of the thread (e.g. not UserData).
"""
raise NotImplementedError
def fetch(self):
# TODO: This
raise NotImplementedError
def wave(self, first: bool = True) -> str:
"""Wave hello to the thread.
Args:
first: Whether to wave first or wave back
Example:
Wave back to the thread.
>>> thread.wave(False)
"""
data = self._to_send_data()
data["action_type"] = "ma-type:user-generated-message"
data["lightweight_action_attachment[lwa_state]"] = (
"INITIATED" if first else "RECIPROCATED"
)
data["lightweight_action_attachment[lwa_type]"] = "WAVE"
message_id, thread_id = self.session._do_send_request(data)
return message_id
def send_text(
self,
text: str,
mentions: Iterable["_models.Mention"] = None,
files: Iterable[Tuple[str, str]] = None,
reply_to_id: str = None,
) -> str:
"""Send a message to the thread.
Args:
text: Text to send
mentions: Optional mentions
files: Optional tuples, each containing an uploaded file's ID and mimetype.
See `ThreadABC.send_files` for an example.
reply_to_id: Optional message to reply to
Example:
>>> mention = fbchat.Mention(thread_id="1234", offset=5, length=2)
>>> thread.send_text("A message", mentions=[mention])
Returns:
The sent message
"""
data = self._to_send_data()
data["action_type"] = "ma-type:user-generated-message"
if text is not None: # To support `send_files`
data["body"] = text
for i, mention in enumerate(mentions or ()):
data.update(mention._to_send_data(i))
if files:
data["has_attachment"] = True
for i, (file_id, mimetype) in enumerate(files or ()):
data["{}s[{}]".format(_util.mimetype_to_key(mimetype), i)] = file_id
if reply_to_id:
data["replied_to_message_id"] = reply_to_id
return self.session._do_send_request(data)
def send_emoji(self, emoji: str, size: "_models.EmojiSize") -> str:
"""Send an emoji to the thread.
Args:
emoji: The emoji to send
size: The size of the emoji
Example:
>>> thread.send_emoji("😀", size=fbchat.EmojiSize.LARGE)
Returns:
The sent message
"""
data = self._to_send_data()
data["action_type"] = "ma-type:user-generated-message"
data["body"] = emoji
data["tags[0]"] = "hot_emoji_size:{}".format(size.name.lower())
return self.session._do_send_request(data)
def send_sticker(self, sticker_id: str) -> str:
"""Send a sticker to the thread.
Args:
sticker_id: ID of the sticker to send
Example:
Send a sticker with the id "1889713947839631"
>>> thread.send_sticker("1889713947839631")
Returns:
The sent message
"""
data = self._to_send_data()
data["action_type"] = "ma-type:user-generated-message"
data["sticker_id"] = sticker_id
return self.session._do_send_request(data)
def _send_location(self, current, latitude, longitude):
data = self._to_send_data()
data["action_type"] = "ma-type:user-generated-message"
data["location_attachment[coordinates][latitude]"] = latitude
data["location_attachment[coordinates][longitude]"] = longitude
data["location_attachment[is_current_location]"] = current
return self.session._do_send_request(data)
def send_location(self, latitude: float, longitude: float):
"""Send a given location to a thread as the user's current location.
Args:
latitude: The location latitude
longitude: The location longitude
Example:
Send a location in London, United Kingdom.
>>> thread.send_location(51.5287718, -0.2416815)
"""
self._send_location(True, latitude=latitude, longitude=longitude)
def send_pinned_location(self, latitude: float, longitude: float):
"""Send a given location to a thread as a pinned location.
Args:
latitude: The location latitude
longitude: The location longitude
Example:
Send a pinned location in Beijing, China.
>>> thread.send_location(39.9390731, 116.117273)
"""
self._send_location(False, latitude=latitude, longitude=longitude)
def send_files(self, files: Iterable[Tuple[str, str]]):
"""Send files from file IDs to a thread.
`files` should be a list of tuples, with a file's ID and mimetype.
Example:
Upload and send a video to a thread.
>>> with open("video.mp4", "rb") as f:
... files = client.upload([("video.mp4", f, "video/mp4")])
>>>
>>> thread.send_files(files)
"""
return self.send_text(text=None, files=files)
# xmd = {"quick_replies": []}
# for quick_reply in quick_replies:
# # TODO: Move this to `_quick_reply.py`
# q = dict()
# q["content_type"] = quick_reply._type
# q["payload"] = quick_reply.payload
# q["external_payload"] = quick_reply.external_payload
# q["data"] = quick_reply.data
# if quick_reply.is_response:
# q["ignore_for_webhook"] = False
# if isinstance(quick_reply, _quick_reply.QuickReplyText):
# q["title"] = quick_reply.title
# if not isinstance(quick_reply, _quick_reply.QuickReplyLocation):
# q["image_url"] = quick_reply.image_url
# xmd["quick_replies"].append(q)
# if len(quick_replies) == 1 and quick_replies[0].is_response:
# xmd["quick_replies"] = xmd["quick_replies"][0]
# data["platform_xmd"] = _util.json_minimal(xmd)
# TODO: This!
# def quick_reply(self, quick_reply: QuickReply, payload=None):
# """Reply to chosen quick reply.
#
# Args:
# quick_reply: Quick reply to reply to
# payload: Optional answer to the quick reply
# """
# if isinstance(quick_reply, QuickReplyText):
# new = QuickReplyText(
# payload=quick_reply.payload,
# external_payload=quick_reply.external_payload,
# data=quick_reply.data,
# is_response=True,
# title=quick_reply.title,
# image_url=quick_reply.image_url,
# )
# return self.send(Message(text=quick_reply.title, quick_replies=[new]))
# elif isinstance(quick_reply, QuickReplyLocation):
# if not isinstance(payload, LocationAttachment):
# raise TypeError("Payload must be an instance of `LocationAttachment`")
# return self.send_location(payload)
# elif isinstance(quick_reply, QuickReplyEmail):
# new = QuickReplyEmail(
# payload=payload if payload else self.get_emails()[0],
# external_payload=quick_reply.payload,
# data=quick_reply.data,
# is_response=True,
# image_url=quick_reply.image_url,
# )
# return self.send(Message(text=payload, quick_replies=[new]))
# elif isinstance(quick_reply, QuickReplyPhoneNumber):
# new = QuickReplyPhoneNumber(
# payload=payload if payload else self.get_phone_numbers()[0],
# external_payload=quick_reply.payload,
# data=quick_reply.data,
# is_response=True,
# image_url=quick_reply.image_url,
# )
# return self.send(Message(text=payload, quick_replies=[new]))
def _search_messages(self, query, offset, limit):
data = {
"query": query,
"snippetOffset": offset,
"snippetLimit": limit,
"identifier": "thread_fbid",
"thread_fbid": self.id,
}
j = self.session._payload_post("/ajax/mercury/search_snippets.php?dpr=1", data)
result = j["search_snippets"][query].get(self.id)
if not result:
return (0, [])
thread = self._copy()
snippets = [
_models.MessageSnippet._parse(thread, snippet)
for snippet in result["snippets"]
]
return (result["num_total_snippets"], snippets)
def search_messages(
self, query: str, limit: int
) -> Iterable["_models.MessageSnippet"]:
"""Find and get message IDs by query.
Warning! If someone send a message to the thread that matches the query, while
we're searching, some snippets will get returned twice.
This is fundamentally unfixable, it's just how the endpoint is implemented.
The returned message snippets are ordered by last sent first.
Args:
query: Text to search for
limit: Max. number of message snippets to retrieve
Example:
Fetch the latest message in the thread that matches the query.
>>> (message,) = thread.search_messages("abc", limit=1)
>>> message.text
"Some text and abc"
"""
offset = 0
# The max limit is measured empirically to 420, safe default chosen below
for limit in _util.get_limits(limit, max_limit=50):
_, snippets = self._search_messages(query, offset, limit)
yield from snippets
if len(snippets) < limit:
return # No more data to fetch
offset += limit
def _fetch_messages(self, limit, before):
params = {
"id": self.id,
"message_limit": limit,
"load_messages": True,
"load_read_receipts": True,
# "load_delivery_receipts": False,
# "is_work_teamwork_not_putting_muted_in_unreads": False,
"before": _util.datetime_to_millis(before) if before else None,
}
(j,) = self.session._graphql_requests(
_graphql.from_doc_id("1860982147341344", params) # 2696825200377124
)
if j.get("message_thread") is None:
raise _exception.ParseError("Could not fetch messages", data=j)
# TODO: Should we parse the returned thread data, too?
read_receipts = j["message_thread"]["read_receipts"]["nodes"]
thread = self._copy()
return [
_models.MessageData._from_graphql(thread, message, read_receipts)
for message in j["message_thread"]["messages"]["nodes"]
]
def fetch_messages(self, limit: Optional[int]) -> Iterable["_models.Message"]:
"""Fetch messages in a thread.
The returned messages are ordered by last sent first.
Args:
limit: Max. number of threads to retrieve. If ``None``, all threads will be
retrieved.
Example:
>>> for message in thread.fetch_messages(limit=5)
... print(message.text)
...
A message
Another message
None
A fourth message
"""
# This is measured empirically as 210 in extreme cases, fairly safe default
# chosen below
MAX_BATCH_LIMIT = 100
before = None
for limit in _util.get_limits(limit, MAX_BATCH_LIMIT):
messages = self._fetch_messages(limit, before)
messages.reverse()
if before:
# Strip the first messages
yield from messages[1:]
else:
yield from messages
if len(messages) < MAX_BATCH_LIMIT:
return # No more data to fetch
before = messages[-1].created_at
def _fetch_images(self, limit, after):
data = {"id": self.id, "first": limit, "after": after}
(j,) = self.session._graphql_requests(
_graphql.from_query_id("515216185516880", data)
)
if not j[self.id]:
raise _exception.ParseError("Could not find images", data=j)
result = j[self.id]["message_shared_media"]
rtn = []
for edge in result["edges"]:
node = edge["node"]
type_ = node["__typename"]
if type_ == "MessageImage":
rtn.append(_models.ImageAttachment._from_list(node))
elif type_ == "MessageVideo":
rtn.append(_models.VideoAttachment._from_list(node))
else:
log.warning("Unknown image type %s, data: %s", type_, edge)
rtn.append(None)
# result["page_info"]["has_next_page"] is not correct when limit > 12
return (result["page_info"]["end_cursor"], rtn)
def fetch_images(self, limit: Optional[int]) -> Iterable["_models.Attachment"]:
"""Fetch images/videos posted in the thread.
The returned images are ordered by last sent first.
Args:
limit: Max. number of images to retrieve. If ``None``, all images will be
retrieved.
Example:
>>> for image in thread.fetch_messages(limit=3)
... print(image.id)
...
1234
2345
"""
cursor = None
# The max limit on this request is unknown, so we set it reasonably high
# This way `limit=None` also still works
for limit in _util.get_limits(limit, max_limit=1000):
cursor, images = self._fetch_images(limit, cursor)
if not images:
return # No more data to fetch
for image in images:
if image:
yield image
def set_nickname(self, user_id: str, nickname: str):
"""Change the nickname of a user in the thread.
Args:
user_id: User that will have their nickname changed
nickname: New nickname
Example:
>>> thread.set_nickname("1234", "A nickname")
"""
data = {
"nickname": nickname,
"participant_id": user_id,
"thread_or_other_fbid": self.id,
}
j = self.session._payload_post(
"/messaging/save_thread_nickname/?source=thread_settings&dpr=1", data
)
def set_color(self, color: str):
"""Change thread color.
The new color must be one of the following::
"#0084ff", "#44bec7", "#ffc300", "#fa3c4c", "#d696bb", "#6699cc",
"#13cf13", "#ff7e29", "#e68585", "#7646ff", "#20cef5", "#67b868",
"#d4a88c", "#ff5ca1", "#a695c7", "#ff7ca8", "#1adb5b", "#f01d6a",
"#ff9c19" or "#0edcde".
This list is subject to change in the future!
The default when creating a new thread is ``"#0084ff"``.
Args:
color: New thread color
Example:
Set the thread color to "Coral Pink".
>>> thread.set_color("#e68585")
"""
if color not in SETABLE_COLORS:
raise ValueError(
"Invalid color! Please use one of: {}".format(SETABLE_COLORS)
)
# Set color to "" if DEFAULT_COLOR. Just how the endpoint works...
if color == DEFAULT_COLOR:
color = ""
data = {"color_choice": color, "thread_or_other_fbid": self.id}
j = self.session._payload_post(
"/messaging/save_thread_color/?source=thread_settings&dpr=1", data
)
# def set_theme(self, theme_id: str):
# data = {
# "client_mutation_id": "0",
# "actor_id": self.session.user.id,
# "thread_id": self.id,
# "theme_id": theme_id,
# "source": "SETTINGS",
# }
# j = self.session._graphql_requests(
# _graphql.from_doc_id("1768656253222505", {"data": data})
# )
def set_emoji(self, emoji: Optional[str]):
"""Change thread emoji.
Args:
emoji: New thread emoji. If ``None``, will be set to the default "LIKE" icon
Example:
Set the thread emoji to "😊".
>>> thread.set_emoji("😊")
"""
data = {"emoji_choice": emoji, "thread_or_other_fbid": self.id}
# While changing the emoji, the Facebook web client actually sends multiple
# different requests, though only this one is required to make the change.
j = self.session._payload_post(
"/messaging/save_thread_emoji/?source=thread_settings&dpr=1", data
)
def forward_attachment(self, attachment_id: str):
"""Forward an attachment.
Args:
attachment_id: Attachment ID to forward
Example:
>>> thread.forward_attachment("1234")
"""
data = {
"attachment_id": attachment_id,
"recipient_map[{}]".format(_util.generate_offline_threading_id()): self.id,
}
j = self.session._payload_post("/mercury/attachments/forward/", data)
if not j.get("success"):
raise _exception.ExternalError("Failed forwarding attachment", j["error"])
def _set_typing(self, typing):
data = {
"typ": "1" if typing else "0",
"thread": self.id,
# TODO: This
# "to": self.id if isinstance(self, _user.User) else "",
"source": "mercury-chat",
}
j = self.session._payload_post("/ajax/messaging/typ.php", data)
def start_typing(self):
"""Set the current user to start typing in the thread.
Example:
>>> thread.start_typing()
"""
self._set_typing(True)
def stop_typing(self):
"""Set the current user to stop typing in the thread.
Example:
>>> thread.stop_typing()
"""
self._set_typing(False)
def create_plan(
self,
name: str,
at: datetime.datetime,
location_name: str = None,
location_id: str = None,
):
"""Create a new plan.
# TODO: Arguments
Args:
name: Name of the new plan
at: When the plan is for
Example:
>>> thread.create_plan(...)
"""
return _models.Plan._create(self, name, at, location_name, location_id)
def create_poll(self, question: str, options: Mapping[str, bool]):
"""Create poll in a thread.
Args:
question: The question
options: Options and whether you want to select the option
Example:
>>> thread.create_poll("Test poll", {"Option 1": True, "Option 2": False})
"""
# We're using ordered dictionaries, because the Facebook endpoint that parses
# the POST parameters is badly implemented, and deals with ordering the options
# wrongly. If you can find a way to fix this for the endpoint, or if you find
# another endpoint, please do suggest it ;)
data = collections.OrderedDict(
[("question_text", question), ("target_id", self.id)]
)
for i, (text, vote) in enumerate(options.items()):
data["option_text_array[{}]".format(i)] = text
data["option_is_selected_array[{}]".format(i)] = "1" if vote else "0"
j = self.session._payload_post(
"/messaging/group_polling/create_poll/?dpr=1", data
)
if j.get("status") != "success":
raise _exception.ExternalError(
"Failed creating poll: {}".format(j.get("errorTitle")),
j.get("errorMessage"),
)
def mute(self, duration: datetime.timedelta = None):
"""Mute the thread.
Args:
duration: Time to mute, use ``None`` to mute forever
Example:
>>> import datetime
>>> thread.mute(datetime.timedelta(days=2))
"""
if duration is None:
setting = "-1"
else:
setting = str(_util.timedelta_to_seconds(duration))
data = {"mute_settings": setting, "thread_fbid": self.id}
j = self.session._payload_post(
"/ajax/mercury/change_mute_thread.php?dpr=1", data
)
def unmute(self):
"""Unmute the thread.
Example:
>>> thread.unmute()
"""
return self.mute(datetime.timedelta(0))
def _mute_reactions(self, mode: bool):
data = {"reactions_mute_mode": "1" if mode else "0", "thread_fbid": self.id}
j = self.session._payload_post(
"/ajax/mercury/change_reactions_mute_thread/?dpr=1", data
)
def mute_reactions(self):
"""Mute thread reactions."""
self._mute_reactions(True)
def unmute_reactions(self):
"""Unmute thread reactions."""
self._mute_reactions(False)
def _mute_mentions(self, mode: bool):
data = {"mentions_mute_mode": "1" if mode else "0", "thread_fbid": self.id}
j = self.session._payload_post(
"/ajax/mercury/change_mentions_mute_thread/?dpr=1", data
)
def mute_mentions(self):
"""Mute thread mentions."""
self._mute_mentions(True)
def unmute_mentions(self):
"""Unmute thread mentions."""
self._mute_mentions(False)
def mark_as_spam(self):
"""Mark the thread as spam, and delete it."""
data = {"id": self.id}
j = self.session._payload_post("/ajax/mercury/mark_spam.php?dpr=1", data)
@staticmethod
def _delete_many(session, thread_ids):
data = {}
for i, id_ in enumerate(thread_ids):
data["ids[{}]".format(i)] = id_
# Not needed any more
# j = session._payload_post("/ajax/mercury/change_pinned_status.php?dpr=1", ...)
# Both /ajax/mercury/delete_threads.php (with an s) doesn't work
j = session._payload_post("/ajax/mercury/delete_thread.php", data)
def delete(self):
"""Delete the thread.
If you want to delete multiple threads, please use `Client.delete_threads`.
Example:
>>> message.delete()
"""
self._delete_many(self.session, [self.id])
def _forced_fetch(self, message_id: str) -> dict:
params = {
"thread_and_message_id": {"thread_id": self.id, "message_id": message_id}
}
(j,) = self.session._graphql_requests(
_graphql.from_doc_id("1768656253222505", params)
)
return j
@staticmethod
def _parse_color(inp: Optional[str]) -> str:
if not inp:
return DEFAULT_COLOR
# Strip the alpha value, and lower the string
return "#{}".format(inp[2:].lower())
@staticmethod
def _parse_customization_info(data: Any) -> MutableMapping[str, Any]:
if not data or not data.get("customization_info"):
return {"emoji": None, "color": DEFAULT_COLOR}
info = data["customization_info"]
rtn = {
"emoji": info.get("emoji"),
"color": ThreadABC._parse_color(info.get("outgoing_bubble_color")),
}
if (
data.get("thread_type") == "GROUP"
or data.get("is_group_thread")
or data.get("thread_key", {}).get("thread_fbid")
):
rtn["nicknames"] = {}
for k in info.get("participant_customizations", []):
rtn["nicknames"][k["participant_id"]] = k.get("nickname")
elif info.get("participant_customizations"):
user_id = data.get("thread_key", {}).get("other_user_id") or data.get("id")
pc = info["participant_customizations"]
if len(pc) > 0:
if pc[0].get("participant_id") == user_id:
rtn["nickname"] = pc[0].get("nickname")
else:
rtn["own_nickname"] = pc[0].get("nickname")
if len(pc) > 1:
if pc[1].get("participant_id") == user_id:
rtn["nickname"] = pc[1].get("nickname")
else:
rtn["own_nickname"] = pc[1].get("nickname")
return rtn
@staticmethod
def _parse_participants(session, data) -> Iterable["ThreadABC"]:
from . import _user, _group, _page
for node in data["nodes"]:
actor = node["messaging_actor"]
typename = actor["__typename"]
thread_id = actor["id"]
if typename == "User":
yield _user.User(session=session, id=thread_id)
elif typename == "MessageThread":
# MessageThread => Group thread
yield _group.Group(session=session, id=thread_id)
elif typename == "Page":
yield _page.Page(session=session, id=thread_id)
elif typename == "Group":
# We don't handle Facebook "Groups"
pass
else:
log.warning("Unknown type %r in %s", typename, data)
@attrs_default
class Thread(ThreadABC):
"""Represents a Facebook thread, where the actual type is unknown.
Implements parts of `ThreadABC`, call the method to figure out if your use case is
supported. Otherwise, you'll have to use an `User`/`Group`/`Page` object.
Note: This list may change in minor versions!
"""
#: The session to use when making requests.
session = attr.ib(type=_session.Session)
#: The unique identifier of the thread.
id = attr.ib(converter=str, type=str)
def _to_send_data(self):
raise NotImplementedError(
"The method you called is not supported on raw Thread objects."
" Please use an appropriate User/Group/Page object instead!"
)
def _copy(self) -> "Thread":
return Thread(session=self.session, id=self.id)

278
fbchat/_threads/_group.py Normal file
View File

@@ -0,0 +1,278 @@
import attr
import datetime
from ._abc import ThreadABC
from . import _user
from .._common import attrs_default
from .. import _util, _session, _graphql, _models
from typing import Sequence, Iterable, Set, Mapping
@attrs_default
class Group(ThreadABC):
"""Represents a Facebook group. Implements `ThreadABC`.
Example:
>>> group = fbchat.Group(session=session, id="1234")
"""
#: The session to use when making requests.
session = attr.ib(type=_session.Session)
#: The group's unique identifier.
id = attr.ib(converter=str, type=str)
def _to_send_data(self):
return {"thread_fbid": self.id}
def _copy(self) -> "Group":
return Group(session=self.session, id=self.id)
def add_participants(self, user_ids: Iterable[str]):
"""Add users to the group.
Args:
user_ids: One or more user IDs to add
Example:
>>> group.add_participants(["1234", "2345"])
"""
data = self._to_send_data()
data["action_type"] = "ma-type:log-message"
data["log_message_type"] = "log:subscribe"
for i, user_id in enumerate(user_ids):
if user_id == self.session.user.id:
raise ValueError(
"Error when adding users: Cannot add self to group thread"
)
else:
data[
"log_message_data[added_participants][{}]".format(i)
] = "fbid:{}".format(user_id)
return self.session._do_send_request(data)
def remove_participant(self, user_id: str):
"""Remove user from the group.
Args:
user_id: User ID to remove
Example:
>>> group.remove_participant("1234")
"""
data = {"uid": user_id, "tid": self.id}
j = self.session._payload_post("/chat/remove_participants/", data)
def _admin_status(self, user_ids: Iterable[str], status: bool):
data = {"add": status, "thread_fbid": self.id}
for i, user_id in enumerate(user_ids):
data["admin_ids[{}]".format(i)] = str(user_id)
j = self.session._payload_post("/messaging/save_admins/?dpr=1", data)
def add_admins(self, user_ids: Iterable[str]):
"""Set specified users as group admins.
Args:
user_ids: One or more user IDs to set admin
Example:
>>> group.add_admins(["1234", "2345"])
"""
self._admin_status(user_ids, True)
def remove_admins(self, user_ids: Iterable[str]):
"""Remove admin status from specified users.
Args:
user_ids: One or more user IDs to remove admin
Example:
>>> group.remove_admins(["1234", "2345"])
"""
self._admin_status(user_ids, False)
def set_title(self, title: str):
"""Change title of the group.
Args:
title: New title
Example:
>>> group.set_title("Abc")
"""
data = {"thread_name": title, "thread_id": self.id}
j = self.session._payload_post("/messaging/set_thread_name/?dpr=1", data)
def set_image(self, image_id: str):
"""Change the group image from an image id.
Args:
image_id: ID of uploaded image
Example:
Upload an image, and use it as the group image.
>>> with open("image.png", "rb") as f:
... (file,) = client.upload([("image.png", f, "image/png")])
...
>>> group.set_image(file[0])
"""
data = {"thread_image_id": image_id, "thread_id": self.id}
j = self.session._payload_post("/messaging/set_thread_image/?dpr=1", data)
def set_approval_mode(self, require_admin_approval: bool):
"""Change the group's approval mode.
Args:
require_admin_approval: True or False
Example:
>>> group.set_approval_mode(False)
"""
data = {"set_mode": int(require_admin_approval), "thread_fbid": self.id}
j = self.session._payload_post("/messaging/set_approval_mode/?dpr=1", data)
def _users_approval(self, user_ids: Iterable[str], approve: bool):
data = {
"client_mutation_id": "0",
"actor_id": self.session.user.id,
"thread_fbid": self.id,
"user_ids": list(user_ids),
"response": "ACCEPT" if approve else "DENY",
"surface": "ADMIN_MODEL_APPROVAL_CENTER",
}
(j,) = self.session._graphql_requests(
_graphql.from_doc_id("1574519202665847", {"data": data})
)
def accept_users(self, user_ids: Iterable[str]):
"""Accept users to the group from the group's approval.
Args:
user_ids: One or more user IDs to accept
Example:
>>> group.accept_users(["1234", "2345"])
"""
self._users_approval(user_ids, True)
def deny_users(self, user_ids: Iterable[str]):
"""Deny users from joining the group.
Args:
user_ids: One or more user IDs to deny
Example:
>>> group.deny_users(["1234", "2345"])
"""
self._users_approval(user_ids, False)
@attrs_default
class GroupData(Group):
"""Represents data about a Facebook group.
Inherits `Group`, and implements `ThreadABC`.
"""
#: The group's picture
photo = attr.ib(None, type="_models.Image")
#: The name of the group
name = attr.ib(None, type=str)
#: When the group was last active / when the last message was sent
last_active = attr.ib(None, type=datetime.datetime)
#: Number of messages in the group
message_count = attr.ib(None, type=int)
#: Set `Plan`
plan = attr.ib(None, type="_models.PlanData")
#: The group thread's participant user ids
participants = attr.ib(factory=set, type=Set[str])
#: A dictionary, containing user nicknames mapped to their IDs
nicknames = attr.ib(factory=dict, type=Mapping[str, str])
#: The groups's message color
color = attr.ib(None, type=str)
#: The groups's default emoji
emoji = attr.ib(None, type=str)
# User ids of thread admins
admins = attr.ib(factory=set, type=Set[str])
# True if users need approval to join
approval_mode = attr.ib(None, type=bool)
# Set containing user IDs requesting to join
approval_requests = attr.ib(factory=set, type=Set[str])
# Link for joining group
join_link = attr.ib(None, type=str)
@classmethod
def _from_graphql(cls, session, data):
if data.get("image") is None:
data["image"] = {}
c_info = cls._parse_customization_info(data)
last_active = None
if "last_message" in data:
last_active = _util.millis_to_datetime(
int(data["last_message"]["nodes"][0]["timestamp_precise"])
)
plan = None
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
plan = _models.PlanData._from_graphql(
session, data["event_reminders"]["nodes"][0]
)
return cls(
session=session,
id=data["thread_key"]["thread_fbid"],
participants=list(
cls._parse_participants(session, data["all_participants"])
),
nicknames=c_info.get("nicknames"),
color=c_info["color"],
emoji=c_info["emoji"],
admins=set([node.get("id") for node in data.get("thread_admins")]),
approval_mode=bool(data.get("approval_mode"))
if data.get("approval_mode") is not None
else None,
approval_requests=set(
node["requester"]["id"]
for node in data["group_approval_queue"]["nodes"]
)
if data.get("group_approval_queue")
else None,
join_link=data["joinable_mode"].get("link"),
photo=_models.Image._from_uri_or_none(data["image"]),
name=data.get("name"),
message_count=data.get("messages_count"),
last_active=last_active,
plan=plan,
)
@attrs_default
class NewGroup(ThreadABC):
"""Helper class to create new groups.
TODO: Complete this!
Construct this class with the desired users, and call a method like `wave`, to...
"""
#: The session to use when making requests.
session = attr.ib(type=_session.Session)
#: The users that should be added to the group.
_users = attr.ib(type=Sequence["_user.User"])
@property
def id(self):
raise NotImplementedError(
"The method you called is not supported on NewGroup objects."
" Please use the supported methods to create the group, before attempting"
" to call the method."
)
def _to_send_data(self) -> dict:
return {
"specific_to_list[{}]".format(i): "fbid:{}".format(user.id)
for i, user in enumerate(self._users)
}

80
fbchat/_threads/_page.py Normal file
View File

@@ -0,0 +1,80 @@
import attr
import datetime
from ._abc import ThreadABC
from .._common import attrs_default
from .. import _session, _models
@attrs_default
class Page(ThreadABC):
"""Represents a Facebook page. Implements `ThreadABC`.
Example:
>>> page = fbchat.Page(session=session, id="1234")
"""
# TODO: Implement pages properly, the implementation is lacking in a lot of places!
#: The session to use when making requests.
session = attr.ib(type=_session.Session)
#: The unique identifier of the page.
id = attr.ib(converter=str, type=str)
def _to_send_data(self):
return {"other_user_fbid": self.id}
def _copy(self) -> "Page":
return Page(session=self.session, id=self.id)
@attrs_default
class PageData(Page):
"""Represents data about a Facebook page.
Inherits `Page`, and implements `ThreadABC`.
"""
#: The page's picture
photo = attr.ib(type="_models.Image")
#: The name of the page
name = attr.ib(type=str)
#: When the thread was last active / when the last message was sent
last_active = attr.ib(None, type=datetime.datetime)
#: Number of messages in the thread
message_count = attr.ib(None, type=int)
#: Set `Plan`
plan = attr.ib(None, type="_models.PlanData")
#: The page's custom URL
url = attr.ib(None, type=str)
#: The name of the page's location city
city = attr.ib(None, type=str)
#: Amount of likes the page has
likes = attr.ib(None, type=int)
#: Some extra information about the page
sub_title = attr.ib(None, type=str)
#: The page's category
category = attr.ib(None, type=str)
@classmethod
def _from_graphql(cls, session, data):
if data.get("profile_picture") is None:
data["profile_picture"] = {}
if data.get("city") is None:
data["city"] = {}
plan = None
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
plan = _models.PlanData._from_graphql(
session, data["event_reminders"]["nodes"][0]
)
return cls(
session=session,
id=data["id"],
url=data.get("url"),
city=data.get("city").get("name"),
category=data.get("category_type"),
photo=_models.Image._from_uri(data["profile_picture"]),
name=data["name"],
message_count=data.get("messages_count"),
plan=plan,
)

219
fbchat/_threads/_user.py Normal file
View File

@@ -0,0 +1,219 @@
import attr
import datetime
from ._abc import ThreadABC
from .._common import log, attrs_default
from .. import _util, _session, _models
GENDERS = {
# For standard requests
0: "unknown",
1: "female_singular",
2: "male_singular",
3: "female_singular_guess",
4: "male_singular_guess",
5: "mixed",
6: "neuter_singular",
7: "unknown_singular",
8: "female_plural",
9: "male_plural",
10: "neuter_plural",
11: "unknown_plural",
# For graphql requests
"UNKNOWN": "unknown",
"FEMALE": "female_singular",
"MALE": "male_singular",
# '': 'female_singular_guess',
# '': 'male_singular_guess',
# '': 'mixed',
"NEUTER": "neuter_singular",
# '': 'unknown_singular',
# '': 'female_plural',
# '': 'male_plural',
# '': 'neuter_plural',
# '': 'unknown_plural',
}
@attrs_default
class User(ThreadABC):
"""Represents a Facebook user. Implements `ThreadABC`.
Example:
>>> user = fbchat.User(session=session, id="1234")
"""
#: The session to use when making requests.
session = attr.ib(type=_session.Session)
#: The user's unique identifier.
id = attr.ib(converter=str, type=str)
def _to_send_data(self):
return {
"other_user_fbid": self.id,
# The entry below is to support .wave
"specific_to_list[0]": "fbid:{}".format(self.id),
}
def _copy(self) -> "User":
return User(session=self.session, id=self.id)
def confirm_friend_request(self):
"""Confirm a friend request, adding the user to your friend list.
Example:
>>> user.confirm_friend_request()
"""
data = {"to_friend": self.id, "action": "confirm"}
j = self.session._payload_post("/ajax/add_friend/action.php?dpr=1", data)
def remove_friend(self):
"""Remove the user from the client's friend list.
Example:
>>> user.remove_friend()
"""
data = {"uid": self.id}
j = self.session._payload_post("/ajax/profile/removefriendconfirm.php", data)
def block(self):
"""Block messages from the user.
Example:
>>> user.block()
"""
data = {"fbid": self.id}
j = self.session._payload_post("/messaging/block_messages/?dpr=1", data)
def unblock(self):
"""Unblock a previously blocked user.
Example:
>>> user.unblock()
"""
data = {"fbid": self.id}
j = self.session._payload_post("/messaging/unblock_messages/?dpr=1", data)
@attrs_default
class UserData(User):
"""Represents data about a Facebook user.
Inherits `User`, and implements `ThreadABC`.
"""
#: The user's picture
photo = attr.ib(type="_models.Image")
#: The name of the user
name = attr.ib(type=str)
#: Whether the user and the client are friends
is_friend = attr.ib(type=bool)
#: The users first name
first_name = attr.ib(type=str)
#: The users last name
last_name = attr.ib(None, type=str)
#: Datetime when the thread was last active / when the last message was sent
last_active = attr.ib(None, type=datetime.datetime)
#: Number of messages in the thread
message_count = attr.ib(None, type=int)
#: Set `Plan`
plan = attr.ib(None, type="_models.PlanData")
#: The profile URL. ``None`` for Messenger-only users
url = attr.ib(None, type=str)
#: The user's gender
gender = attr.ib(None, type=str)
#: From 0 to 1. How close the client is to the user
affinity = attr.ib(None, type=float)
#: The user's nickname
nickname = attr.ib(None, type=str)
#: The clients nickname, as seen by the user
own_nickname = attr.ib(None, type=str)
#: The message color
color = attr.ib(None, type=str)
#: The default emoji
emoji = attr.ib(None, type=str)
@staticmethod
def _get_other_user(data):
(user,) = (
node["messaging_actor"]
for node in data["all_participants"]["nodes"]
if node["messaging_actor"]["id"] == data["thread_key"]["other_user_id"]
)
return user
@classmethod
def _from_graphql(cls, session, data):
c_info = cls._parse_customization_info(data)
plan = None
if data.get("event_reminders") and data["event_reminders"].get("nodes"):
plan = _models.PlanData._from_graphql(
session, data["event_reminders"]["nodes"][0]
)
return cls(
session=session,
id=data["id"],
url=data["url"],
first_name=data["first_name"],
last_name=data.get("last_name"),
is_friend=data["is_viewer_friend"],
gender=GENDERS.get(data["gender"]),
affinity=data.get("viewer_affinity"),
nickname=c_info.get("nickname"),
color=c_info["color"],
emoji=c_info["emoji"],
own_nickname=c_info.get("own_nickname"),
photo=_models.Image._from_uri(data["profile_picture"]),
name=data["name"],
message_count=data.get("messages_count"),
plan=plan,
)
@classmethod
def _from_thread_fetch(cls, session, data):
user = cls._get_other_user(data)
if user["__typename"] != "User":
# TODO: Add Page._from_thread_fetch, and parse it there
log.warning("Tried to parse %s as a user.", user["__typename"])
return None
c_info = cls._parse_customization_info(data)
plan = None
if data["event_reminders"]["nodes"]:
plan = _models.PlanData._from_graphql(
session, data["event_reminders"]["nodes"][0]
)
return cls(
session=session,
id=user["id"],
url=user["url"],
name=user["name"],
first_name=user["short_name"],
is_friend=user["is_viewer_friend"],
gender=GENDERS.get(user["gender"]),
nickname=c_info.get("nickname"),
color=c_info["color"],
emoji=c_info["emoji"],
own_nickname=c_info.get("own_nickname"),
photo=_models.Image._from_uri(user["big_image_src"]),
message_count=data["messages_count"],
last_active=_util.millis_to_datetime(int(data["updated_time_precise"])),
plan=plan,
)
@classmethod
def _from_all_fetch(cls, session, data):
return cls(
session=session,
id=data["id"],
first_name=data["firstName"],
url=data["uri"],
photo=_models.Image(url=data["thumbSrc"]),
name=data["name"],
is_friend=data["is_friend"],
gender=GENDERS.get(data["gender"]),
)

160
fbchat/_util.py Normal file
View File

@@ -0,0 +1,160 @@
import datetime
import json
import time
import random
import urllib.parse
from ._common import log
from . import _exception
from typing import Iterable, Optional, Any, Mapping, Sequence
#: Default list of user agents
USER_AGENTS = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/601.1.10 (KHTML, like Gecko) Version/8.0.5 Safari/601.1.10",
"Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/22.0.1207.1 Safari/537.1",
"Mozilla/5.0 (X11; CrOS i686 2268.111.0) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.57 Safari/536.11",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6",
]
def get_limits(limit: Optional[int], max_limit: int) -> Iterable[int]:
"""Helper that generates limits based on a max limit."""
if limit is None:
# Generate infinite items
while True:
yield max_limit
if limit < 0:
raise ValueError("Limit cannot be negative")
# Generate n items
yield from [max_limit] * (limit // max_limit)
remainder = limit % max_limit
if remainder:
yield remainder
def json_minimal(data: Any) -> str:
"""Get JSON data in minimal form."""
return json.dumps(data, separators=(",", ":"))
def strip_json_cruft(text: str) -> str:
"""Removes `for(;;);` (and other cruft) that preceeds JSON responses."""
try:
return text[text.index("{") :]
except ValueError as e:
raise _exception.ParseError("No JSON object found", data=text) from e
def parse_json(text: str) -> Any:
try:
return json.loads(text)
except ValueError as e:
raise _exception.ParseError("Error while parsing JSON", data=text) from e
def generate_offline_threading_id():
ret = datetime_to_millis(datetime.datetime.utcnow())
value = int(random.random() * 4294967295)
string = ("0000000000000000000000" + format(value, "b"))[-22:]
msgs = format(ret, "b") + string
return str(int(msgs, 2))
def get_jsmods_require(require) -> Mapping[str, Sequence[Any]]:
rtn = {}
for item in require:
if len(item) == 1:
(module,) = item
rtn[module] = []
continue
method = "{}.{}".format(item[0], item[1])
requirements = item[2]
arguments = item[3]
rtn[method] = arguments
return rtn
def get_jsmods_define(define) -> Mapping[str, Mapping[str, Any]]:
rtn = {}
for item in define:
module, requirements, data, _ = item
rtn[module] = data
return rtn
def mimetype_to_key(mimetype: str) -> str:
if not mimetype:
return "file_id"
if mimetype == "image/gif":
return "gif_id"
x = mimetype.split("/")
if x[0] in ["video", "image", "audio"]:
return "%s_id" % x[0]
return "file_id"
def get_url_parameter(url: str, param: str) -> Optional[str]:
params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
if not params.get(param):
return None
return params[param][0]
def seconds_to_datetime(timestamp_in_seconds: float) -> datetime.datetime:
"""Convert an UTC timestamp to a timezone-aware datetime object."""
# `.utcfromtimestamp` will return a "naive" datetime object, which is why we use the
# following:
return datetime.datetime.fromtimestamp(
timestamp_in_seconds, tz=datetime.timezone.utc
)
def millis_to_datetime(timestamp_in_milliseconds: int) -> datetime.datetime:
"""Convert an UTC timestamp, in milliseconds, to a timezone-aware datetime."""
return seconds_to_datetime(timestamp_in_milliseconds / 1000)
def datetime_to_seconds(dt: datetime.datetime) -> int:
"""Convert a datetime to an UTC timestamp.
Naive datetime objects are presumed to represent time in the system timezone.
The returned seconds will be rounded to the nearest whole number.
"""
# We could've implemented some fancy "convert naive timezones to UTC" logic, but
# it's not really worth the effort.
return round(dt.timestamp())
def datetime_to_millis(dt: datetime.datetime) -> int:
"""Convert a datetime to an UTC timestamp, in milliseconds.
Naive datetime objects are presumed to represent time in the system timezone.
The returned milliseconds will be rounded to the nearest whole number.
"""
return round(dt.timestamp() * 1000)
def seconds_to_timedelta(seconds: float) -> datetime.timedelta:
"""Convert seconds to a timedelta."""
return datetime.timedelta(seconds=seconds)
def millis_to_timedelta(milliseconds: int) -> datetime.timedelta:
"""Convert a duration (in milliseconds) to a timedelta object."""
return datetime.timedelta(milliseconds=milliseconds)
def timedelta_to_seconds(td: datetime.timedelta) -> int:
"""Convert a timedelta to seconds.
The returned seconds will be rounded to the nearest whole number.
"""
return round(td.total_seconds())

File diff suppressed because it is too large Load Diff

View File

@@ -1,283 +0,0 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import json
import re
from .models import *
from .utils import *
# Shameless copy from https://stackoverflow.com/a/8730674
FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
WHITESPACE = re.compile(r'[ \t\n\r]*', FLAGS)
class ConcatJSONDecoder(json.JSONDecoder):
def decode(self, s, _w=WHITESPACE.match):
s_len = len(s)
objs = []
end = 0
while end != s_len:
obj, end = self.raw_decode(s, idx=_w(s, end).end())
end = _w(s, end).end()
objs.append(obj)
return objs
# End shameless copy
def graphql_color_to_enum(color):
if color is None:
return None
if len(color) == 0:
return ThreadColor.MESSENGER_BLUE
try:
return ThreadColor('#{}'.format(color[2:].lower()))
except ValueError:
raise Exception('Could not get ThreadColor from color: {}'.format(color))
def get_customization_info(thread):
if thread is None or thread.get('customization_info') is None:
return {}
info = thread['customization_info']
rtn = {
'emoji': info.get('emoji'),
'color': graphql_color_to_enum(info.get('outgoing_bubble_color'))
}
if thread.get('thread_type') == 'GROUP' or thread.get('is_group_thread') or thread.get('thread_key', {}).get('thread_fbid'):
rtn['nicknames'] = {}
for k in info['participant_customizations']:
rtn['nicknames'][k['participant_id']] = k.get('nickname')
elif info.get('participant_customizations'):
_id = thread.get('thread_key', {}).get('other_user_id') or thread.get('id')
if info['participant_customizations'][0]['participant_id'] == _id:
rtn['nickname'] = info['participant_customizations'][0]
rtn['own_nickname'] = info['participant_customizations'][1]
elif info['participant_customizations'][1]['participant_id'] == _id:
rtn['nickname'] = info['participant_customizations'][1]
rtn['own_nickname'] = info['participant_customizations'][0]
else:
raise Exception('No participant matching the user {} found: {}'.format(_id, info['participant_customizations']))
return rtn
def graphql_to_message(message):
if message.get('message_sender') is None:
message['message_sender'] = {}
if message.get('message') is None:
message['message'] = {}
is_read = None
if message.get('unread') is not None:
is_read = not message['unread']
return Message(
message.get('message_id'),
author=message.get('message_sender').get('id'),
timestamp=message.get('timestamp_precise'),
is_read=is_read,
reactions=message.get('message_reactions'),
text=message.get('message').get('text'),
mentions=[Mention(m.get('entity', {}).get('id'), offset=m.get('offset'), length=m.get('length')) for m in message.get('message').get('ranges', [])],
sticker=message.get('sticker'),
attachments=message.get('blob_attachments')
)
def graphql_to_user(user):
if user.get('profile_picture') is None:
user['profile_picture'] = {}
c_info = get_customization_info(user)
return User(
user['id'],
url=user.get('url'),
first_name=user.get('first_name'),
last_name=user.get('last_name'),
is_friend=user.get('is_viewer_friend'),
gender=GENDERS[user.get('gender')],
affinity=user.get('affinity'),
nickname=c_info.get('nickname'),
color=c_info.get('color'),
emoji=c_info.get('emoji'),
own_nickname=c_info.get('own_nickname'),
photo=user['profile_picture'].get('uri'),
name=user.get('name')
)
def graphql_to_group(group):
if group.get('image') is None:
group['image'] = {}
c_info = get_customization_info(group)
return Group(
group['thread_key']['thread_fbid'],
participants=set([node['messaging_actor']['id'] for node in group['all_participants']['nodes']]),
nicknames=c_info.get('nicknames'),
color=c_info.get('color'),
emoji=c_info.get('emoji'),
photo=group['image'].get('uri'),
name=group.get('name')
)
def graphql_to_page(page):
if page.get('profile_picture') is None:
page['profile_picture'] = {}
if page.get('city') is None:
page['city'] = {}
return Page(
page['id'],
url=page.get('url'),
city=page.get('city').get('name'),
category=page.get('category_type'),
photo=page['profile_picture'].get('uri'),
name=page.get('name')
)
def graphql_queries_to_json(*queries):
"""
Queries should be a list of GraphQL objects
"""
rtn = {}
for i, query in enumerate(queries):
rtn['q{}'.format(i)] = query.value
return json.dumps(rtn)
def graphql_response_to_json(content):
j = json.loads(content, cls=ConcatJSONDecoder)
rtn = [None]*(len(j))
for x in j:
if 'error_results' in x:
del rtn[-1]
continue
check_json(x)
[(key, value)] = x.items()
check_json(value)
if 'response' in value:
rtn[int(key[1:])] = value['response']
else:
rtn[int(key[1:])] = value['data']
log.debug(rtn)
return rtn
class GraphQL(object):
def __init__(self, query=None, doc_id=None, params={}):
if query is not None:
self.value = {
'priority': 0,
'q': query,
'query_params': params
}
elif doc_id is not None:
self.value = {
'doc_id': doc_id,
'query_params': params
}
else:
raise Exception('A query or doc_id must be specified')
FRAGMENT_USER = """
QueryFragment User: User {
id,
name,
first_name,
last_name,
profile_picture.width(<pic_size>).height(<pic_size>) {
uri
},
is_viewer_friend,
url,
gender,
viewer_affinity
}
"""
FRAGMENT_GROUP = """
QueryFragment Group: MessageThread {
name,
thread_key {
thread_fbid
},
image {
uri
},
is_group_thread,
all_participants {
nodes {
messaging_actor {
id
}
}
},
customization_info {
participant_customizations {
participant_id,
nickname
},
outgoing_bubble_color,
emoji
}
}
"""
FRAGMENT_PAGE = """
QueryFragment Page: Page {
id,
name,
profile_picture.width(32).height(32) {
uri
},
url,
category_type,
city {
name
}
}
"""
SEARCH_USER = """
Query SearchUser(<search> = '', <limit> = 1) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes {
@User
}
}
}
}
""" + FRAGMENT_USER
SEARCH_GROUP = """
Query SearchGroup(<search> = '', <limit> = 1, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes {
@Group
}
}
}
}
""" + FRAGMENT_GROUP
SEARCH_PAGE = """
Query SearchPage(<search> = '', <limit> = 1) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@Page
}
}
}
}
""" + FRAGMENT_PAGE
SEARCH_THREAD = """
Query SearchThread(<search> = '', <limit> = 1) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {
__typename,
@User,
@Group,
@Page
}
}
}
}
""" + FRAGMENT_USER + FRAGMENT_GROUP + FRAGMENT_PAGE

View File

@@ -1,224 +0,0 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import enum
class Thread(object):
#: The unique identifier of the thread. Can be used a `thread_id`. See :ref:`intro_threads` for more info
uid = str
#: Specifies the type of thread. Can be used a `thread_type`. See :ref:`intro_threads` for more info
type = None
#: The thread's picture
photo = str
#: The name of the thread
name = str
def __init__(self, _type, uid, photo=None, name=None):
"""Represents a Facebook thread"""
self.uid = str(uid)
self.type = _type
self.photo = photo
self.name = name
def __repr__(self):
return self.__unicode__()
def __unicode__(self):
return '<{} {} ({})>'.format(self.type.name, self.name, self.uid)
class User(Thread):
#: The profile url
url = str
#: The users first name
first_name = str
#: The users last name
last_name = str
#: Whether the user and the client are friends
is_friend = bool
#: The user's gender
gender = str
#: From 0 to 1. How close the client is to the user
affinity = float
#: The user's nickname
nickname = str
#: The clients nickname, as seen by the user
own_nickname = str
#: A :class:`ThreadColor`. The message color
color = None
#: The default emoji
emoji = str
def __init__(self, uid, url=None, first_name=None, last_name=None, is_friend=None, gender=None, affinity=None, nickname=None, own_nickname=None, color=None, emoji=None, **kwargs):
"""Represents a Facebook user. Inherits `Thread`"""
super(User, self).__init__(ThreadType.USER, uid, **kwargs)
self.url = url
self.first_name = first_name
self.last_name = last_name
self.is_friend = is_friend
self.gender = gender
self.affinity = affinity
self.nickname = nickname
self.own_nickname = own_nickname
self.color = color
self.emoji = emoji
class Group(Thread):
#: Unique list (set) of the group thread's participant user IDs
participants = set
#: Dict, containing user nicknames mapped to their IDs
nicknames = dict
#: A :class:`ThreadColor`. The groups's message color
color = None
#: The groups's default emoji
emoji = str
def __init__(self, uid, participants=set(), nicknames=[], color=None, emoji=None, **kwargs):
"""Represents a Facebook group. Inherits `Thread`"""
super(Group, self).__init__(ThreadType.GROUP, uid, **kwargs)
self.participants = participants
self.nicknames = nicknames
self.color = color
self.emoji = emoji
class Page(Thread):
#: The page's custom url
url = str
#: The name of the page's location city
city = str
#: Amount of likes the page has
likes = int
#: Some extra information about the page
sub_title = str
#: The page's category
category = str
def __init__(self, uid, url=None, city=None, likes=None, sub_title=None, category=None, **kwargs):
"""Represents a Facebook page. Inherits `Thread`"""
super(Page, self).__init__(ThreadType.PAGE, uid, **kwargs)
self.url = url
self.city = city
self.likes = likes
self.sub_title = sub_title
self.category = category
class Message(object):
#: The message ID
uid = str
#: ID of the sender
author = int
#: Timestamp of when the message was sent
timestamp = str
#: Whether the message is read
is_read = bool
#: A list of message reactions
reactions = list
#: The actual message
text = str
#: A list of :class:`Mention` objects
mentions = list
#: An ID of a sent sticker
sticker = str
#: A list of attachments
attachments = list
def __init__(self, uid, author=None, timestamp=None, is_read=None, reactions=[], text=None, mentions=[], sticker=None, attachments=[]):
"""Represents a Facebook message"""
self.uid = uid
self.author = author
self.timestamp = timestamp
self.is_read = is_read
self.reactions = reactions
self.text = text
self.mentions = mentions
self.sticker = sticker
self.attachments = attachments
class Mention(object):
#: The user ID the mention is pointing at
user_id = str
#: The character where the mention starts
offset = int
#: The length of the mention
length = int
def __init__(self, user_id, offset=0, length=10):
"""Represents a @mention"""
self.user_id = user_id
self.offset = offset
self.length = length
class Enum(enum.Enum):
"""Used internally by fbchat to support enumerations"""
def __repr__(self):
# For documentation:
return '{}.{}'.format(type(self).__name__, self.name)
class ThreadType(Enum):
"""Used to specify what type of Facebook thread is being used. See :ref:`intro_threads` for more info"""
USER = 1
GROUP = 2
PAGE = 3
class TypingStatus(Enum):
"""Used to specify whether the user is typing or has stopped typing"""
STOPPED = 0
TYPING = 1
class EmojiSize(Enum):
"""Used to specify the size of a sent emoji"""
LARGE = '369239383222810'
MEDIUM = '369239343222814'
SMALL = '369239263222822'
class ThreadColor(Enum):
"""Used to specify a thread colors"""
MESSENGER_BLUE = ''
VIKING = '#44bec7'
GOLDEN_POPPY = '#ffc300'
RADICAL_RED = '#fa3c4c'
SHOCKING = '#d696bb'
PICTON_BLUE = '#6699cc'
FREE_SPEECH_GREEN = '#13cf13'
PUMPKIN = '#ff7e29'
LIGHT_CORAL = '#e68585'
MEDIUM_SLATE_BLUE = '#7646ff'
DEEP_SKY_BLUE = '#20cef5'
FERN = '#67b868'
CAMEO = '#d4a88c'
BRILLIANT_ROSE = '#ff5ca1'
BILOBA_FLOWER = '#a695c7'
class MessageReaction(Enum):
"""Used to specify a message reaction"""
LOVE = '😍'
SMILE = '😆'
WOW = '😮'
SAD = '😢'
ANGRY = '😠'
YES = '👍'
NO = '👎'
LIKES = {
'large': EmojiSize.LARGE,
'medium': EmojiSize.MEDIUM,
'small': EmojiSize.SMALL,
'l': EmojiSize.LARGE,
'm': EmojiSize.MEDIUM,
's': EmojiSize.SMALL
}
MessageReactionFix = {
'😍': ('0001f60d', '%F0%9F%98%8D'),
'😆': ('0001f606', '%F0%9F%98%86'),
'😮': ('0001f62e', '%F0%9F%98%AE'),
'😢': ('0001f622', '%F0%9F%98%A2'),
'😠': ('0001f620', '%F0%9F%98%A0'),
'👍': ('0001f44d', '%F0%9F%91%8D'),
'👎': ('0001f44e', '%F0%9F%91%8E')
}

0
fbchat/py.typed Normal file
View File

View File

@@ -1,173 +0,0 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import re
import json
from time import time
from random import random
import warnings
import logging
from .models import *
# Python 2's `input` executes the input, whereas `raw_input` just returns the input
try:
input = raw_input
except NameError:
pass
# Log settings
log = logging.getLogger("client")
log.setLevel(logging.DEBUG)
# Creates the console handler
handler = logging.StreamHandler()
log.addHandler(handler)
#: Default list of user agents
USER_AGENTS = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/601.1.10 (KHTML, like Gecko) Version/8.0.5 Safari/601.1.10",
"Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/22.0.1207.1 Safari/537.1",
"Mozilla/5.0 (X11; CrOS i686 2268.111.0) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.57 Safari/536.11",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6"
]
GENDERS = {
# For standard requests
0: 'unknown',
1: 'female_singular',
2: 'male_singular',
3: 'female_singular_guess',
4: 'male_singular_guess',
5: 'mixed',
6: 'neuter_singular',
7: 'unknown_singular',
8: 'female_plural',
9: 'male_plural',
10: 'neuter_plural',
11: 'unknown_plural',
# For graphql requests
#'': 'unknown',
'FEMALE': 'female_singular',
'MALE': 'male_singular',
#'': 'female_singular_guess',
#'': 'male_singular_guess',
#'': 'mixed',
#'': 'neuter_singular',
#'': 'unknown_singular',
#'': 'female_plural',
#'': 'male_plural',
#'': 'neuter_plural',
#'': 'unknown_plural',
None: None
}
class ReqUrl(object):
"""A class containing all urls used by `fbchat`"""
SEARCH = "https://www.facebook.com/ajax/typeahead/search.php"
LOGIN = "https://m.facebook.com/login.php?login_attempt=1"
SEND = "https://www.facebook.com/messaging/send/"
THREAD_SYNC = "https://www.facebook.com/ajax/mercury/thread_sync.php"
THREADS = "https://www.facebook.com/ajax/mercury/threadlist_info.php"
MESSAGES = "https://www.facebook.com/ajax/mercury/thread_info.php"
READ_STATUS = "https://www.facebook.com/ajax/mercury/change_read_status.php"
DELIVERED = "https://www.facebook.com/ajax/mercury/delivery_receipts.php"
MARK_SEEN = "https://www.facebook.com/ajax/mercury/mark_seen.php"
BASE = "https://www.facebook.com"
MOBILE = "https://m.facebook.com/"
STICKY = "https://0-edge-chat.facebook.com/pull"
PING = "https://0-edge-chat.facebook.com/active_ping"
UPLOAD = "https://upload.facebook.com/ajax/mercury/upload.php"
INFO = "https://www.facebook.com/chat/user_info/"
CONNECT = "https://www.facebook.com/ajax/add_friend/action.php?dpr=1"
REMOVE_USER = "https://www.facebook.com/chat/remove_participants/"
LOGOUT = "https://www.facebook.com/logout.php"
ALL_USERS = "https://www.facebook.com/chat/user_info_all"
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"
GRAPHQL = "https://www.facebook.com/api/graphqlbatch/"
facebookEncoding = 'UTF-8'
def now():
return int(time()*1000)
def strip_to_json(text):
try:
return text[text.index('{'):]
except ValueError:
raise Exception('No JSON object found: {}, {}'.format(repr(text), text.index('{')))
def get_decoded_r(r):
return get_decoded(r._content)
def get_decoded(content):
return content.decode(facebookEncoding)
def get_json(r):
return json.loads(strip_to_json(get_decoded_r(r)))
def digitToChar(digit):
if digit < 10:
return str(digit)
return chr(ord('a') + digit - 10)
def str_base(number, base):
if number < 0:
return '-' + str_base(-number, base)
(d, m) = divmod(number, base)
if d > 0:
return str_base(d, base) + digitToChar(m)
return digitToChar(m)
def generateMessageID(client_id=None):
k = now()
l = int(random() * 4294967295)
return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id)
def getSignatureID():
return hex(int(random() * 2147483648))
def generateOfflineThreadingID():
ret = now()
value = int(random() * 4294967295)
string = ("0000000000000000000000" + format(value, 'b'))[-22:]
msgs = format(ret, 'b') + string
return str(int(msgs, 2))
def check_json(j):
if 'error' in j and j['error'] is not None:
if 'errorDescription' in j:
# 'errorDescription' is in the users own language!
raise Exception('Error #{} when sending request: {}'.format(j['error'], j['errorDescription']))
elif 'debug_info' in j['error']:
raise Exception('Error #{} when sending request: {}'.format(j['error']['code'], repr(j['error']['debug_info'])))
else:
raise Exception('Error {} when sending request'.format(j['error']))
def checkRequest(r, do_json_check=True):
if not r.ok:
raise Exception('Error when sending request: Got {} response'.format(r.status_code))
content = get_decoded_r(r)
if content is None or len(content) == 0:
raise Exception('Error when sending request: Got empty response')
if do_json_check:
content = strip_to_json(content)
try:
j = json.loads(content)
except Exception as e:
raise Exception('Error while parsing JSON: {}'.format(repr(content)), e)
check_json(j)
return j
else:
return content

64
pyproject.toml Normal file
View File

@@ -0,0 +1,64 @@
[tool.black]
line-length = 88
target-version = ['py36', 'py37', 'py38']
[build-system]
requires = ["flit"]
build-backend = "flit.buildapi"
[tool.flit.metadata]
module = "fbchat"
author = "Taehoon Kim"
author-email = "carpedm20@gmail.com"
maintainer = "Mads Marquart"
maintainer-email = "madsmtm@gmail.com"
home-page = "https://github.com/carpedm20/fbchat/"
requires = [
"attrs>=19.1",
"requests~=2.19",
"beautifulsoup4~=4.0",
"paho-mqtt~=1.5",
]
description-file = "README.rst"
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Natural Language :: English",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Communications :: Chat",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
]
requires-python = ">=3.5, <4.0"
keywords = "Facebook FB Messenger Library Chat Api Bot"
license = "BSD 3-Clause"
[tool.flit.metadata.urls]
Documentation = "https://fbchat.readthedocs.io/"
Repository = "https://github.com/carpedm20/fbchat/"
[tool.flit.metadata.requires-extra]
test = [
"pytest>=4.3,<6.0",
]
docs = [
"sphinx~=2.0",
"sphinxcontrib-spelling~=4.0",
"sphinx-autodoc-typehints~=1.10",
]
lint = [
"black",
]

6
pytest.ini Normal file
View File

@@ -0,0 +1,6 @@
[pytest]
xfail_strict = true
addopts =
--strict
testpaths = tests
filterwarnings = error

View File

@@ -1,3 +0,0 @@
requests
lxml
beautifulsoup4

View File

@@ -1,81 +0,0 @@
#!/usr/bin/env python
"""
Setup script for fbchat
"""
import os
try:
from setuptools import setup
except ImportError:
from distutils.core import setup
with open('README.rst') as f:
readme_content = f.read().strip()
try:
requirements = [line.rstrip('\n') for line in open(os.path.join('fbchat.egg-info', 'requires.txt'))]
except FileNotFoundError:
requirements = [line.rstrip('\n') for line in open('requirements.txt')]
version = None
author = None
email = None
source = None
description = None
with open(os.path.join('fbchat', '__init__.py')) as f:
for line in f:
if line.strip().startswith('__version__'):
version = line.split('=')[1].strip().replace('"', '').replace("'", '')
elif line.strip().startswith('__author__'):
author = line.split('=')[1].strip().replace('"', '').replace("'", '')
elif line.strip().startswith('__email__'):
email = line.split('=')[1].strip().replace('"', '').replace("'", '')
elif line.strip().startswith('__source__'):
source = line.split('=')[1].strip().replace('"', '').replace("'", '')
elif line.strip().startswith('__description__'):
description = line.split('=')[1].strip().replace('"', '').replace("'", '')
elif None not in (version, author, email, source, description):
break
setup(
name='fbchat',
author=author,
author_email=email,
license='BSD License',
keywords=["facebook chat fbchat"],
description=description,
long_description=readme_content,
classifiers=[
'Development Status :: 2 - Pre-Alpha',
'Intended Audience :: Developers',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'License :: OSI Approved :: BSD License',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy',
'Programming Language :: Python',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Communications :: Chat',
],
include_package_data=True,
packages=['fbchat'],
install_requires=requirements,
url=source,
version=version,
zip_safe=True,
)

238
tests.py
View File

@@ -1,238 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import json
import logging
import unittest
from getpass import getpass
from sys import argv
from os import path, chdir
from glob import glob
from fbchat import Client
from fbchat.models import *
import py_compile
logging_level = logging.ERROR
"""
Testing script for `fbchat`.
Full documentation on https://fbchat.readthedocs.io/
"""
class CustomClient(Client):
def __init__(self, *args, **kwargs):
self.got_qprimer = False
super(type(self), self).__init__(*args, **kwargs)
def onQprimer(self, msg, **kwargs):
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())
client.logout()
self.assertFalse(client.isLoggedIn())
with self.assertRaises(Exception):
client.login('<email>', '<password>', max_tries=1)
client.login(email, password)
self.assertTrue(client.isLoggedIn())
def test_sessions(self):
global client
session_cookies = client.getSession()
client = CustomClient(email, password, session_cookies=session_cookies, logging_level=logging_level)
self.assertTrue(client.isLoggedIn())
def test_defaultThread(self):
# setDefaultThread
client.setDefaultThread(group_id, ThreadType.GROUP)
self.assertTrue(client.sendMessage('test_default_recipient★'))
client.setDefaultThread(user_id, ThreadType.USER)
self.assertTrue(client.sendMessage('test_default_recipient★'))
# resetDefaultThread
client.resetDefaultThread()
with self.assertRaises(ValueError):
client.sendMessage('should_not_send')
def test_fetchAllUsers(self):
users = client.fetchAllUsers()
self.assertGreater(len(users), 0)
def test_searchFor(self):
users = client.searchForUsers('Mark Zuckerberg')
self.assertGreater(len(users), 0)
u = users[0]
# Test if values are set correctly
self.assertEqual(u.uid, '4')
self.assertEqual(u.type, ThreadType.USER)
self.assertEqual(u.photo[:4], 'http')
self.assertEqual(u.url[:4], 'http')
self.assertEqual(u.name, 'Mark Zuckerberg')
group_name = client.changeThreadTitle('tést_searchFor', thread_id=group_id, thread_type=ThreadType.GROUP)
groups = client.searchForGroups('')
self.assertGreater(len(groups), 0)
def test_sendEmoji(self):
self.assertIsNotNone(client.sendEmoji(size=EmojiSize.SMALL, thread_id=user_id, thread_type=ThreadType.USER))
self.assertIsNotNone(client.sendEmoji(size=EmojiSize.MEDIUM, thread_id=user_id, thread_type=ThreadType.USER))
self.assertIsNotNone(client.sendEmoji('😆', EmojiSize.LARGE, user_id, ThreadType.USER))
self.assertIsNotNone(client.sendEmoji(size=EmojiSize.SMALL, thread_id=group_id, thread_type=ThreadType.GROUP))
self.assertIsNotNone(client.sendEmoji(size=EmojiSize.MEDIUM, thread_id=group_id, thread_type=ThreadType.GROUP))
self.assertIsNotNone(client.sendEmoji('😆', EmojiSize.LARGE, group_id, ThreadType.GROUP))
def test_sendMessage(self):
self.assertIsNotNone(client.sendMessage('test_send_user★', user_id, ThreadType.USER))
self.assertIsNotNone(client.sendMessage('test_send_group★', group_id, ThreadType.GROUP))
with self.assertRaises(Exception):
client.sendMessage('test_send_user_should_fail★', user_id, ThreadType.GROUP)
with self.assertRaises(Exception):
client.sendMessage('test_send_group_should_fail★', group_id, ThreadType.USER)
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_id, ThreadType.USER))
self.assertTrue(client.sendRemoteImage(image_url, 'test_send_group_images_remote★', group_id, ThreadType.GROUP))
self.assertTrue(client.sendLocalImage(image_local_url, 'test_send_group_images_local★', user_id, ThreadType.USER))
self.assertTrue(client.sendLocalImage(image_local_url, 'test_send_group_images_local★', group_id, ThreadType.GROUP))
def test_fetchThreadList(self):
client.fetchThreadList(offset=0, limit=20)
def test_fetchThreadMessages(self):
client.sendMessage('test_user_getThreadInfo★', thread_id=user_id, thread_type=ThreadType.USER)
messages = client.fetchThreadMessages(thread_id=user_id, limit=1)
self.assertEqual(messages[0].author, client.uid)
self.assertEqual(messages[0].text, 'test_user_getThreadInfo★')
client.sendMessage('test_group_getThreadInfo★', thread_id=group_id, thread_type=ThreadType.GROUP)
messages = client.fetchThreadMessages(thread_id=group_id, limit=1)
self.assertEqual(messages[0].author, client.uid)
self.assertEqual(messages[0].text, 'test_group_getThreadInfo★')
def test_listen(self):
client.startListening()
client.doOneListen()
client.stopListening()
self.assertTrue(client.got_qprimer)
def test_fetchInfo(self):
info = client.fetchUserInfo('4')['4']
self.assertEqual(info.name, 'Mark Zuckerberg')
info = client.fetchGroupInfo(group_id)[group_id]
self.assertEqual(info.type, ThreadType.GROUP)
def test_removeAddFromGroup(self):
client.removeUserFromGroup(user_id, thread_id=group_id)
client.addUsersToGroup(user_id, thread_id=group_id)
def test_changeThreadTitle(self):
client.changeThreadTitle('test_changeThreadTitle★', thread_id=group_id, thread_type=ThreadType.GROUP)
client.changeThreadTitle('test_changeThreadTitle★', thread_id=user_id, thread_type=ThreadType.USER)
def test_changeNickname(self):
client.changeNickname('test_changeNicknameSelf★', client.uid, thread_id=user_id, thread_type=ThreadType.USER)
client.changeNickname('test_changeNicknameOther★', user_id, thread_id=user_id, thread_type=ThreadType.USER)
client.changeNickname('test_changeNicknameSelf★', client.uid, thread_id=group_id, thread_type=ThreadType.GROUP)
client.changeNickname('test_changeNicknameOther★', user_id, thread_id=group_id, thread_type=ThreadType.GROUP)
def test_changeThreadEmoji(self):
client.changeThreadEmoji('😀', group_id)
client.changeThreadEmoji('😀', user_id)
client.changeThreadEmoji('😆', group_id)
client.changeThreadEmoji('😆', user_id)
def test_changeThreadColor(self):
client.changeThreadColor(ThreadColor.BRILLIANT_ROSE, group_id)
client.changeThreadColor(ThreadColor.MESSENGER_BLUE, group_id)
client.changeThreadColor(ThreadColor.BRILLIANT_ROSE, user_id)
client.changeThreadColor(ThreadColor.MESSENGER_BLUE, user_id)
def test_reactToMessage(self):
mid = client.sendMessage('test_reactToMessage★', user_id, ThreadType.USER)
client.reactToMessage(mid, MessageReaction.LOVE)
mid = client.sendMessage('test_reactToMessage★', group_id, ThreadType.GROUP)
client.reactToMessage(mid, MessageReaction.LOVE)
def test_setTypingStatus(self):
client.setTypingStatus(TypingStatus.TYPING, thread_id=user_id, thread_type=ThreadType.USER)
client.setTypingStatus(TypingStatus.STOPPED, thread_id=user_id, thread_type=ThreadType.USER)
client.setTypingStatus(TypingStatus.TYPING, thread_id=group_id, thread_type=ThreadType.GROUP)
client.setTypingStatus(TypingStatus.STOPPED, thread_id=group_id, thread_type=ThreadType.GROUP)
def start_test(param_client, param_group_id, param_user_id, tests=[]):
global client
global group_id
global user_id
client = param_client
group_id = param_group_id
user_id = param_user_id
tests = ['test_' + test if 'test_' != test[:5] else test for test in tests]
if len(tests) == 0:
suite = unittest.TestLoader().loadTestsFromTestCase(TestFbchat)
else:
suite = unittest.TestSuite(map(TestFbchat, tests))
print('Starting test(s)')
unittest.TextTestRunner(verbosity=2).run(suite)
client = None
if __name__ == '__main__':
# Python 3 does not use raw_input, whereas Python 2 does
try:
input = raw_input
except Exception as e:
pass
try:
with open(path.join(path.dirname(__file__), 'tests/my_data.json'), 'r') as f:
json = json.load(f)
email = json['email']
password = json['password']
user_id = json['user_thread_id']
group_id = json['group_thread_id']
except (IOError, IndexError) as e:
email = input('Email: ')
password = getpass()
group_id = input('Please enter a group thread id (To test group functionality): ')
user_id = input('Please enter a user thread id (To test kicking/adding functionality): ')
print('Logging in...')
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_id, user_id, argv[1:])

9
tests/conftest.py Normal file
View File

@@ -0,0 +1,9 @@
import pytest
import fbchat
@pytest.fixture(scope="session")
def session():
return fbchat.Session(
user_id="31415926536", fb_dtsg=None, revision=None, session=None
)

View File

@@ -1,6 +0,0 @@
{
"email": "",
"password": "",
"user_thread_id": "",
"group_thread_id": ""
}

View File

@@ -0,0 +1,175 @@
import datetime
import pytest
from fbchat import (
ParseError,
User,
Group,
Message,
MessageData,
UnknownEvent,
ReactionEvent,
UserStatusEvent,
LiveLocationEvent,
UnsendEvent,
MessageReplyEvent,
)
from fbchat._events import parse_client_delta, parse_client_payloads
def test_reaction_event_added(session):
data = {
"threadKey": {"otherUserFbId": 1234},
"messageId": "mid.$XYZ",
"action": 0,
"userId": 4321,
"reaction": "😍",
"senderId": 4321,
"offlineThreadingId": "6623596674408921967",
}
thread = User(session=session, id="1234")
assert ReactionEvent(
author=User(session=session, id="4321"),
thread=thread,
message=Message(thread=thread, id="mid.$XYZ"),
reaction="😍",
) == parse_client_delta(session, {"deltaMessageReaction": data})
def test_reaction_event_removed(session):
data = {
"threadKey": {"threadFbId": 1234},
"messageId": "mid.$XYZ",
"action": 1,
"userId": 4321,
"senderId": 4321,
"offlineThreadingId": "6623586106713014836",
}
thread = Group(session=session, id="1234")
assert ReactionEvent(
author=User(session=session, id="4321"),
thread=thread,
message=Message(thread=thread, id="mid.$XYZ"),
reaction=None,
) == parse_client_delta(session, {"deltaMessageReaction": data})
def test_user_status_blocked(session):
data = {
"threadKey": {"otherUserFbId": 1234},
"canViewerReply": False,
"reason": 2,
"actorFbid": 4321,
}
assert UserStatusEvent(
author=User(session=session, id="4321"),
thread=User(session=session, id="1234"),
blocked=True,
) == parse_client_delta(session, {"deltaChangeViewerStatus": data})
def test_user_status_unblocked(session):
data = {
"threadKey": {"otherUserFbId": 1234},
"canViewerReply": True,
"reason": 2,
"actorFbid": 1234,
}
assert UserStatusEvent(
author=User(session=session, id="1234"),
thread=User(session=session, id="1234"),
blocked=False,
) == parse_client_delta(session, {"deltaChangeViewerStatus": data})
@pytest.mark.skip(reason="need to gather test data")
def test_live_location(session):
pass
def test_message_reply(session):
message = {
"messageMetadata": {
"threadKey": {"otherUserFbId": 1234},
"messageId": "mid.$XYZ",
"offlineThreadingId": "112233445566",
"actorFbId": 1234,
"timestamp": 1500000000000,
"tags": ["source:messenger:web", "cg-enabled", "sent", "inbox"],
"threadReadStateEffect": 3,
"skipBumpThread": False,
"skipSnippetUpdate": False,
"unsendType": "can_unsend",
"folderId": {"systemFolderId": 0},
},
"body": "xyz",
"attachments": [],
"irisSeqId": 1111111,
"messageReply": {"replyToMessageId": {"id": "mid.$ABC"}, "status": 0,},
"requestContext": {"apiArgs": "..."},
"irisTags": ["DeltaNewMessage"],
}
reply = {
"messageMetadata": {
"threadKey": {"otherUserFbId": 1234},
"messageId": "mid.$ABC",
"offlineThreadingId": "665544332211",
"actorFbId": 4321,
"timestamp": 1600000000000,
"tags": ["inbox", "sent", "source:messenger:web"],
},
"body": "abc",
"attachments": [],
"requestContext": {"apiArgs": "..."},
"irisTags": [],
}
data = {
"message": message,
"repliedToMessage": reply,
"status": 0,
}
thread = User(session=session, id="1234")
assert MessageReplyEvent(
author=User(session=session, id="1234"),
thread=thread,
message=MessageData(
thread=thread,
id="mid.$XYZ",
author="1234",
created_at=datetime.datetime(
2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc
),
text="xyz",
reply_to_id="mid.$ABC",
),
replied_to=MessageData(
thread=thread,
id="mid.$ABC",
author="4321",
created_at=datetime.datetime(
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
),
text="abc",
),
) == parse_client_delta(session, {"deltaMessageReply": data})
def test_parse_client_delta_unknown(session):
assert UnknownEvent(
source="client payload", data={"abc": 10}
) == parse_client_delta(session, {"abc": 10})
def test_parse_client_payloads_empty(session):
# This is never something that happens, it's just so that we can test the parsing
# payload = '{"deltas":[]}'
payload = [123, 34, 100, 101, 108, 116, 97, 115, 34, 58, 91, 93, 125]
data = {"payload": payload, "class": "ClientPayload"}
assert [] == list(parse_client_payloads(session, data))
def test_parse_client_payloads_invalid(session):
# payload = '{"invalid":"data"}'
payload = [123, 34, 105, 110, 118, 97, 108, 105, 100, 34, 58, 34, 97, 34, 125]
data = {"payload": payload, "class": "ClientPayload"}
with pytest.raises(ParseError, match="Error parsing ClientPayload"):
list(parse_client_payloads(session, data))

View File

@@ -0,0 +1,78 @@
import pytest
import datetime
from fbchat import Group, User, ParseError, Event, ThreadEvent
def test_event_get_thread_group1(session):
data = {
"threadKey": {"threadFbId": 1234},
"messageId": "mid.$gAAT4Sw1WSGh14A3MOFvrsiDvr3Yc",
"offlineThreadingId": "6623583531508397596",
"actorFbId": 4321,
"timestamp": 1500000000000,
"tags": [
"inbox",
"sent",
"tq",
"blindly_apply_message_folder",
"source:messenger:web",
],
}
assert Group(session=session, id="1234") == Event._get_thread(session, data)
def test_event_get_thread_group2(session):
data = {
"actorFbId": "4321",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "112233445566",
"skipBumpThread": False,
"tags": ["source:messenger:web"],
"threadKey": {"threadFbId": "1234"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
}
assert Group(session=session, id="1234") == Event._get_thread(session, data)
def test_event_get_thread_user(session):
data = {
"actorFbId": "4321",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "112233445566",
"skipBumpThread": False,
"skipSnippetUpdate": False,
"tags": ["source:messenger:web"],
"threadKey": {"otherUserFbId": "1234"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
}
assert User(session=session, id="1234") == Event._get_thread(session, data)
def test_event_get_thread_unknown(session):
data = {"threadKey": {"abc": "1234"}}
with pytest.raises(ParseError, match="Could not find thread data"):
Event._get_thread(session, data)
def test_thread_event_parse_metadata(session):
data = {
"actorFbId": "4321",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "112233445566",
"skipBumpThread": False,
"skipSnippetUpdate": False,
"tags": ["source:messenger:web"],
"threadKey": {"otherUserFbId": "1234"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
}
assert (
User(session=session, id="4321"),
User(session=session, id="1234"),
datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == ThreadEvent._parse_metadata(session, {"messageMetadata": data})

View File

@@ -0,0 +1,328 @@
import datetime
import pytest
from fbchat import (
ParseError,
User,
Group,
Message,
MessageData,
ThreadLocation,
UnknownEvent,
PeopleAdded,
PersonRemoved,
TitleSet,
UnfetchedThreadEvent,
MessagesDelivered,
ThreadsRead,
MessageEvent,
ThreadFolder,
)
from fbchat._events import parse_delta
def test_people_added(session):
data = {
"addedParticipants": [
{
"fanoutPolicy": "IRIS_MESSAGE_QUEUE",
"firstName": "Abc",
"fullName": "Abc Def",
"initialFolder": "FOLDER_INBOX",
"initialFolderId": {"systemFolderId": "INBOX"},
"isMessengerUser": False,
"userFbId": "1234",
}
],
"irisSeqId": "11223344",
"irisTags": ["DeltaParticipantsAddedToGroupThread", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "3456",
"adminText": "You added Abc Def to the group.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "1122334455",
"skipBumpThread": False,
"tags": [],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456", "4567"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"class": "ParticipantsAddedToGroupThread",
}
assert PeopleAdded(
author=User(session=session, id="3456"),
thread=Group(session=session, id="4321"),
added=[User(session=session, id="1234")],
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_person_removed(session):
data = {
"irisSeqId": "11223344",
"irisTags": ["DeltaParticipantLeftGroupThread", "is_from_iris_fanout"],
"leftParticipantFbId": "1234",
"messageMetadata": {
"actorFbId": "3456",
"adminText": "You removed Abc Def from the group.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "1122334455",
"skipBumpThread": True,
"tags": [],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456", "4567"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"class": "ParticipantLeftGroupThread",
}
assert PersonRemoved(
author=User(session=session, id="3456"),
thread=Group(session=session, id="4321"),
removed=User(session=session, id="1234"),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_title_set(session):
data = {
"irisSeqId": "11223344",
"irisTags": ["DeltaThreadName", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "3456",
"adminText": "You named the group abc.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "1122334455",
"skipBumpThread": False,
"tags": [],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"name": "abc",
"participants": ["1234", "2345", "3456", "4567"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"class": "ThreadName",
}
assert TitleSet(
author=User(session=session, id="3456"),
thread=Group(session=session, id="4321"),
title="abc",
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_forced_fetch(session):
data = {
"forceInsert": False,
"messageId": "mid.$XYZ",
"threadKey": {"threadFbId": "1234"},
"class": "ForcedFetch",
}
thread = Group(session=session, id="1234")
assert UnfetchedThreadEvent(
thread=thread, message=Message(thread=thread, id="mid.$XYZ")
) == parse_delta(session, data)
def test_forced_fetch_pending(session):
data = {
"forceInsert": False,
"irisSeqId": "1111",
"isLazy": False,
"threadKey": {"threadFbId": "1234"},
"class": "ForcedFetch",
}
assert UnfetchedThreadEvent(
thread=Group(session=session, id="1234"), message=None
) == parse_delta(session, data)
def test_delivery_receipt_group(session):
data = {
"actorFbId": "1234",
"deliveredWatermarkTimestampMs": "1500000000000",
"irisSeqId": "1111111",
"irisTags": ["DeltaDeliveryReceipt"],
"messageIds": ["mid.$XYZ", "mid.$ABC"],
"requestContext": {"apiArgs": {}},
"threadKey": {"threadFbId": "4321"},
"class": "DeliveryReceipt",
}
thread = Group(session=session, id="4321")
assert MessagesDelivered(
author=User(session=session, id="1234"),
thread=thread,
messages=[
Message(thread=thread, id="mid.$XYZ"),
Message(thread=thread, id="mid.$ABC"),
],
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_delivery_receipt_user(session):
data = {
"deliveredWatermarkTimestampMs": "1500000000000",
"irisSeqId": "1111111",
"irisTags": ["DeltaDeliveryReceipt", "is_from_iris_fanout"],
"messageIds": ["mid.$XYZ", "mid.$ABC"],
"requestContext": {"apiArgs": {}},
"threadKey": {"otherUserFbId": "1234"},
"class": "DeliveryReceipt",
}
thread = User(session=session, id="1234")
assert MessagesDelivered(
author=thread,
thread=thread,
messages=[
Message(thread=thread, id="mid.$XYZ"),
Message(thread=thread, id="mid.$ABC"),
],
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_read_receipt(session):
data = {
"actionTimestampMs": "1600000000000",
"actorFbId": "1234",
"irisSeqId": "1111111",
"irisTags": ["DeltaReadReceipt", "is_from_iris_fanout"],
"requestContext": {"apiArgs": {}},
"threadKey": {"threadFbId": "4321"},
"tqSeqId": "1111",
"watermarkTimestampMs": "1500000000000",
"class": "ReadReceipt",
}
assert ThreadsRead(
author=User(session=session, id="1234"),
threads=[Group(session=session, id="4321")],
at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_mark_read(session):
data = {
"actionTimestamp": "1600000000000",
"irisSeqId": "1111111",
"irisTags": ["DeltaMarkRead", "is_from_iris_fanout"],
"threadKeys": [{"threadFbId": "1234"}, {"otherUserFbId": "2345"}],
"tqSeqId": "1111",
"watermarkTimestamp": "1500000000000",
"class": "MarkRead",
}
assert ThreadsRead(
author=session.user,
threads=[Group(session=session, id="1234"), User(session=session, id="2345")],
at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_new_message_user(session):
data = {
"attachments": [],
"body": "test",
"irisSeqId": "1111111",
"irisTags": ["DeltaNewMessage"],
"messageMetadata": {
"actorFbId": "1234",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"skipSnippetUpdate": False,
"tags": ["source:messenger:web"],
"threadKey": {"otherUserFbId": "1234"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1600000000000",
},
"requestContext": {"apiArgs": {}},
"class": "NewMessage",
}
assert MessageEvent(
author=User(session=session, id="1234"),
thread=User(session=session, id="1234"),
message=MessageData(
thread=User(session=session, id="1234"),
id="mid.$XYZ",
author="1234",
text="test",
created_at=datetime.datetime(
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
),
),
at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_new_message_group(session):
data = {
"attachments": [],
"body": "test",
"irisSeqId": "1111111",
"irisTags": ["DeltaNewMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "4321",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:messenger:web"],
"threadKey": {"threadFbId": "1234"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1600000000000",
},
"participants": ["4321", "5432", "6543"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"class": "NewMessage",
}
assert MessageEvent(
author=User(session=session, id="4321"),
thread=Group(session=session, id="1234"),
message=MessageData(
thread=Group(session=session, id="1234"),
id="mid.$XYZ",
author="4321",
text="test",
created_at=datetime.datetime(
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
),
),
at=datetime.datetime(2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc),
) == parse_delta(session, data)
def test_thread_folder(session):
data = {
"class": "ThreadFolder",
"folder": "FOLDER_PENDING",
"irisSeqId": "1111",
"irisTags": ["DeltaThreadFolder", "is_from_iris_fanout"],
"requestContext": {"apiArgs": {}},
"threadKey": {"otherUserFbId": "1234"},
}
assert ThreadFolder(
thread=User(session=session, id="1234"), folder=ThreadLocation.PENDING
) == parse_delta(session, data)
def test_noop(session):
assert parse_delta(session, {"class": "NoOp"}) is None
def test_parse_delta_unknown(session):
data = {"class": "Abc"}
assert UnknownEvent(source="Delta class", data=data) == parse_delta(session, data)

View File

@@ -0,0 +1,958 @@
import datetime
import pytest
from fbchat import (
_util,
ParseError,
User,
Group,
Message,
MessageData,
Poll,
PollOption,
PlanData,
GuestStatus,
UnknownEvent,
ColorSet,
EmojiSet,
NicknameSet,
AdminsAdded,
AdminsRemoved,
ApprovalModeSet,
CallStarted,
CallEnded,
CallJoined,
PollCreated,
PollVoted,
PlanCreated,
PlanEnded,
PlanEdited,
PlanDeleted,
PlanResponded,
)
from fbchat._events import parse_admin_message
def test_color_set(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You changed the chat theme to Orange.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "change_thread_theme",
"untypedData": {
"should_show_icon": "1",
"theme_color": "FFFF7E29",
"accessibility_label": "Orange",
},
"class": "AdminTextMessage",
}
assert ColorSet(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
color="#ff7e29",
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_emoji_set(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You set the emoji to 🌟.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"skipSnippetUpdate": False,
"tags": ["source:generic_admin_text"],
"threadKey": {"otherUserFbId": "1234"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"requestContext": {"apiArgs": {}},
"type": "change_thread_icon",
"untypedData": {
"thread_icon_url": "https://www.facebook.com/images/emoji.php/v9/te0/1/16/1f31f.png",
"thread_icon": "🌟",
},
"class": "AdminTextMessage",
}
assert EmojiSet(
author=User(session=session, id="1234"),
thread=User(session=session, id="1234"),
emoji="🌟",
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_nickname_set(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You set the nickname for Abc Def to abc.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "change_thread_nickname",
"untypedData": {"nickname": "abc", "participant_id": "2345"},
"class": "AdminTextMessage",
}
assert NicknameSet(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
subject=User(session=session, id="2345"),
nickname="abc",
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_nickname_clear(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You cleared your nickname.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"skipSnippetUpdate": False,
"tags": ["source:generic_admin_text"],
"threadKey": {"otherUserFbId": "1234"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"requestContext": {"apiArgs": {}},
"type": "change_thread_nickname",
"untypedData": {"nickname": "", "participant_id": "1234"},
"class": "AdminTextMessage",
}
assert NicknameSet(
author=User(session=session, id="1234"),
thread=User(session=session, id="1234"),
subject=User(session=session, id="1234"),
nickname=None,
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_admins_added(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You added Abc Def as a group admin.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": True,
"tags": ["source:titan:web"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "change_thread_admins",
"untypedData": {
"THREAD_CATEGORY": "GROUP",
"TARGET_ID": "2345",
"ADMIN_TYPE": "0",
"ADMIN_EVENT": "add_admin",
},
"class": "AdminTextMessage",
}
assert AdminsAdded(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
added=[User(session=session, id="2345")],
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_admins_removed(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You removed yourself as a group admin.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": True,
"tags": ["source:titan:web"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "change_thread_admins",
"untypedData": {
"THREAD_CATEGORY": "GROUP",
"TARGET_ID": "1234",
"ADMIN_TYPE": "0",
"ADMIN_EVENT": "remove_admin",
},
"class": "AdminTextMessage",
}
assert AdminsRemoved(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
removed=[User(session=session, id="1234")],
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_approvalmode_set(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You turned on member approval and will review requests to join the group.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": True,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "change_thread_approval_mode",
"untypedData": {"APPROVAL_MODE": "1", "THREAD_CATEGORY": "GROUP"},
"class": "AdminTextMessage",
}
assert ApprovalModeSet(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
require_admin_approval=True,
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_approvalmode_unset(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You turned off member approval. Anyone with the link can join the group.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": True,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "change_thread_approval_mode",
"untypedData": {"APPROVAL_MODE": "0", "THREAD_CATEGORY": "GROUP"},
"class": "AdminTextMessage",
}
assert ApprovalModeSet(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
require_admin_approval=False,
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_call_started(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You started a call.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "messenger_call_log",
"untypedData": {
"call_capture_attachments": "",
"caller_id": "1234",
"conference_name": "MESSENGER:134845267536444",
"rating": "",
"messenger_call_instance_id": "0",
"video": "",
"event": "group_call_started",
"server_info": "XYZ123ABC",
"call_duration": "0",
"callee_id": "0",
},
"class": "AdminTextMessage",
}
data2 = {
"callState": "AUDIO_GROUP_CALL",
"messageMetadata": {
"actorFbId": "1234",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": [],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
},
"serverInfoData": "XYZ123ABC",
"class": "RtcCallData",
}
assert CallStarted(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_group_call_ended(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "The call ended.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "messenger_call_log",
"untypedData": {
"call_capture_attachments": "",
"caller_id": "1234",
"conference_name": "MESSENGER:1234567890",
"rating": "0",
"messenger_call_instance_id": "1234567890",
"video": "",
"event": "group_call_ended",
"server_info": "XYZ123ABC",
"call_duration": "31",
"callee_id": "0",
},
"class": "AdminTextMessage",
}
data2 = {
"callState": "NO_ONGOING_CALL",
"messageMetadata": {
"actorFbId": "1234",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": [],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
},
"class": "RtcCallData",
}
assert CallEnded(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
duration=datetime.timedelta(seconds=31),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_user_call_ended(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "Abc called you.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"skipSnippetUpdate": False,
"tags": ["source:generic_admin_text", "no_push"],
"threadKey": {"otherUserFbId": "1234"},
"threadReadStateEffect": "KEEP_AS_IS",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"requestContext": {"apiArgs": {}},
"type": "messenger_call_log",
"untypedData": {
"call_capture_attachments": "",
"caller_id": "1234",
"conference_name": "MESSENGER:1234567890",
"rating": "0",
"messenger_call_instance_id": "1234567890",
"video": "",
"event": "one_on_one_call_ended",
"server_info": "",
"call_duration": "3",
"callee_id": "100002950119740",
},
"class": "AdminTextMessage",
}
assert CallEnded(
author=User(session=session, id="1234"),
thread=User(session=session, id="1234"),
duration=datetime.timedelta(seconds=3),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_call_joined(session):
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "Abc joined the call.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "participant_joined_group_call",
"untypedData": {
"server_info_data": "XYZ123ABC",
"group_call_type": "0",
"joining_user": "2345",
},
"class": "AdminTextMessage",
}
assert CallJoined(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_poll_created(session):
poll_data = {
"id": "112233",
"text": "A poll",
"total_count": 2,
"viewer_has_voted": "true",
"options": [
{
"id": "1001",
"text": "Option A",
"total_count": 1,
"viewer_has_voted": "true",
"voters": ["1234"],
},
{
"id": "1002",
"text": "Option B",
"total_count": 0,
"viewer_has_voted": "false",
"voters": [],
},
],
}
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You created a poll: A poll.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "group_poll",
"untypedData": {
"added_option_ids": "[]",
"removed_option_ids": "[]",
"question_json": _util.json_minimal(poll_data),
"event_type": "question_creation",
"question_id": "112233",
},
"class": "AdminTextMessage",
}
assert PollCreated(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
poll=Poll(
session=session,
id="112233",
question="A poll",
options=[
PollOption(
id="1001",
text="Option A",
vote=True,
voters=["1234"],
votes_count=1,
),
PollOption(
id="1002", text="Option B", vote=False, voters=[], votes_count=0
),
],
options_count=2,
),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_poll_answered(session):
poll_data = {
"id": "112233",
"text": "A poll",
"total_count": 3,
"viewer_has_voted": "true",
"options": [
{
"id": "1002",
"text": "Option B",
"total_count": 2,
"viewer_has_voted": "true",
"voters": ["1234", "2345"],
},
{
"id": "1003",
"text": "Option C",
"total_count": 1,
"viewer_has_voted": "true",
"voters": ["1234"],
},
{
"id": "1001",
"text": "Option A",
"total_count": 0,
"viewer_has_voted": "false",
"voters": [],
},
],
}
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": 'You changed your vote to "Option B" and 1 other option in the poll: A poll.',
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "group_poll",
"untypedData": {
"added_option_ids": "[1002,1003]",
"removed_option_ids": "[1001]",
"question_json": _util.json_minimal(poll_data),
"event_type": "update_vote",
"question_id": "112233",
},
"class": "AdminTextMessage",
}
assert PollVoted(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
poll=Poll(
session=session,
id="112233",
question="A poll",
options=[
PollOption(
id="1002",
text="Option B",
vote=True,
voters=["1234", "2345"],
votes_count=2,
),
PollOption(
id="1003",
text="Option C",
vote=True,
voters=["1234"],
votes_count=1,
),
PollOption(
id="1001", text="Option A", vote=False, voters=[], votes_count=0
),
],
options_count=3,
),
added_ids=["1002", "1003"],
removed_ids=["1001"],
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_plan_created(session):
guest_list = [
{"guest_list_state": "INVITED", "node": {"id": "3456"}},
{"guest_list_state": "INVITED", "node": {"id": "2345"}},
{"guest_list_state": "GOING", "node": {"id": "1234"}},
]
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You created a plan.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "lightweight_event_create",
"untypedData": {
"event_timezone": "",
"event_creator_id": "1234",
"event_id": "112233",
"event_type": "EVENT",
"event_track_rsvp": "1",
"event_title": "A plan",
"event_time": "1600000000",
"event_seconds_to_notify_before": "3600",
"guest_state_list": _util.json_minimal(guest_list),
},
"class": "AdminTextMessage",
}
assert PlanCreated(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
plan=PlanData(
session=session,
id="112233",
time=datetime.datetime(
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
),
title="A plan",
author_id="1234",
guests={
"1234": GuestStatus.GOING,
"2345": GuestStatus.INVITED,
"3456": GuestStatus.INVITED,
},
),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
@pytest.mark.skip(reason="Need to gather test data")
def test_plan_ended(session):
data = {}
assert PlanEnded(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
plan=PlanData(
session=session,
id="112233",
time=datetime.datetime(
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
),
title="A plan",
author_id="1234",
guests={
"1234": GuestStatus.GOING,
"2345": GuestStatus.INVITED,
"3456": GuestStatus.INVITED,
},
),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_plan_edited(session):
guest_list = [
{"guest_list_state": "INVITED", "node": {"id": "3456"}},
{"guest_list_state": "INVITED", "node": {"id": "2345"}},
{"guest_list_state": "GOING", "node": {"id": "1234"}},
]
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You named the plan A plan.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "lightweight_event_update",
"untypedData": {
"event_creator_id": "1234",
"latitude": "0",
"event_title": "A plan",
"event_seconds_to_notify_before": "3600",
"guest_state_list": _util.json_minimal(guest_list),
"event_end_time": "0",
"event_timezone": "",
"event_id": "112233",
"event_type": "EVENT",
"event_location_id": "2233445566",
"event_location_name": "",
"event_time": "1600000000",
"event_note": "",
"longitude": "0",
},
"class": "AdminTextMessage",
}
assert PlanEdited(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
plan=PlanData(
session=session,
id="112233",
time=datetime.datetime(
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
),
title="A plan",
location_id="2233445566",
author_id="1234",
guests={
"1234": GuestStatus.GOING,
"2345": GuestStatus.INVITED,
"3456": GuestStatus.INVITED,
},
),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_plan_deleted(session):
guest_list = [
{"guest_list_state": "GOING", "node": {"id": "1234"}},
{"guest_list_state": "INVITED", "node": {"id": "3456"}},
{"guest_list_state": "INVITED", "node": {"id": "2345"}},
]
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You deleted the plan A plan for Mon, 20 Jan at 15:30.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "lightweight_event_delete",
"untypedData": {
"event_end_time": "0",
"event_timezone": "",
"event_id": "112233",
"event_type": "EVENT",
"event_location_id": "2233445566",
"latitude": "0",
"event_title": "A plan",
"event_time": "1600000000",
"event_seconds_to_notify_before": "3600",
"guest_state_list": _util.json_minimal(guest_list),
"event_note": "",
"longitude": "0",
},
"class": "AdminTextMessage",
}
assert PlanDeleted(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
plan=PlanData(
session=session,
id="112233",
time=datetime.datetime(
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
),
title="A plan",
location_id="2233445566",
author_id=None,
guests={
"1234": GuestStatus.GOING,
"2345": GuestStatus.INVITED,
"3456": GuestStatus.INVITED,
},
),
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_plan_participation(session):
guest_list = [
{"guest_list_state": "DECLINED", "node": {"id": "1234"}},
{"guest_list_state": "GOING", "node": {"id": "2345"}},
{"guest_list_state": "INVITED", "node": {"id": "3456"}},
]
data = {
"irisSeqId": "1111111",
"irisTags": ["DeltaAdminTextMessage", "is_from_iris_fanout"],
"messageMetadata": {
"actorFbId": "1234",
"adminText": "You responded Can't Go to def.",
"folderId": {"systemFolderId": "INBOX"},
"messageId": "mid.$XYZ",
"offlineThreadingId": "11223344556677889900",
"skipBumpThread": False,
"tags": ["source:titan:web", "no_push"],
"threadKey": {"threadFbId": "4321"},
"threadReadStateEffect": "MARK_UNREAD",
"timestamp": "1500000000000",
"unsendType": "deny_log_message",
},
"participants": ["1234", "2345", "3456"],
"requestContext": {"apiArgs": {}},
"tqSeqId": "1111",
"type": "lightweight_event_rsvp",
"untypedData": {
"event_creator_id": "2345",
"guest_status": "DECLINED",
"latitude": "0",
"event_track_rsvp": "1",
"event_title": "A plan",
"event_seconds_to_notify_before": "3600",
"guest_state_list": _util.json_minimal(guest_list),
"event_end_time": "0",
"event_timezone": "",
"event_id": "112233",
"event_type": "EVENT",
"guest_id": "1234",
"event_location_id": "2233445566",
"event_time": "1600000000",
"event_note": "",
"longitude": "0",
},
"class": "AdminTextMessage",
}
assert PlanResponded(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
plan=PlanData(
session=session,
id="112233",
time=datetime.datetime(
2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc
),
title="A plan",
location_id="2233445566",
author_id="2345",
guests={
"1234": GuestStatus.DECLINED,
"2345": GuestStatus.GOING,
"3456": GuestStatus.INVITED,
},
),
take_part=False,
at=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
) == parse_admin_message(session, data)
def test_parse_admin_message_unknown(session):
data = {"class": "AdminTextMessage", "type": "abc"}
assert UnknownEvent(source="Delta type", data=data) == parse_admin_message(
session, data
)

137
tests/events/test_main.py Normal file
View File

@@ -0,0 +1,137 @@
import datetime
from fbchat import (
_util,
User,
Group,
Message,
ParseError,
UnknownEvent,
Typing,
FriendRequest,
Presence,
ReactionEvent,
UnfetchedThreadEvent,
ActiveStatus,
)
from fbchat._events import parse_events
def test_t_ms_full(session):
"""A full example of parsing of data in /t_ms."""
payload = {
"deltas": [
{
"deltaMessageReaction": {
"threadKey": {"threadFbId": 4321},
"messageId": "mid.$XYZ",
"action": 0,
"userId": 1234,
"reaction": "😢",
"senderId": 1234,
"offlineThreadingId": "1122334455",
}
}
]
}
data = {
"deltas": [
{
"payload": [ord(x) for x in _util.json_minimal(payload)],
"class": "ClientPayload",
},
{"class": "NoOp",},
{
"forceInsert": False,
"messageId": "mid.$ABC",
"threadKey": {"threadFbId": "4321"},
"class": "ForcedFetch",
},
],
"firstDeltaSeqId": 111111,
"lastIssuedSeqId": 111113,
"queueEntityId": 1234,
}
thread = Group(session=session, id="4321")
assert [
ReactionEvent(
author=User(session=session, id="1234"),
thread=thread,
message=Message(thread=thread, id="mid.$XYZ"),
reaction="😢",
),
UnfetchedThreadEvent(
thread=thread, message=Message(thread=thread, id="mid.$ABC"),
),
] == list(parse_events(session, "/t_ms", data))
def test_thread_typing(session):
data = {"sender_fbid": 1234, "state": 0, "type": "typ", "thread": "4321"}
(event,) = parse_events(session, "/thread_typing", data)
assert event == Typing(
author=User(session=session, id="1234"),
thread=Group(session=session, id="4321"),
status=False,
)
def test_orca_typing_notifications(session):
data = {"type": "typ", "sender_fbid": 1234, "state": 1}
(event,) = parse_events(session, "/orca_typing_notifications", data)
assert event == Typing(
author=User(session=session, id="1234"),
thread=User(session=session, id="1234"),
status=True,
)
def test_friend_request(session):
data = {"type": "jewel_requests_add", "from": "1234"}
(event,) = parse_events(session, "/legacy_web", data)
assert event == FriendRequest(author=User(session=session, id="1234"))
def test_orca_presence_inc(session):
data = {
"list_type": "inc",
"list": [
{"u": 1234, "p": 0, "l": 1500000000, "vc": 74},
{"u": 2345, "p": 2, "c": 9969664, "vc": 10},
],
}
(event,) = parse_events(session, "/orca_presence", data)
la = datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc)
assert event == Presence(
statuses={
"1234": ActiveStatus(active=False, last_active=la),
"2345": ActiveStatus(active=True),
},
full=False,
)
def test_orca_presence_full(session):
data = {
"list_type": "full",
"list": [
{"u": 1234, "p": 2, "c": 5767242},
{"u": 2345, "p": 2, "l": 1500000000},
{"u": 3456, "p": 2, "c": 9961482},
{"u": 4567, "p": 0, "l": 1500000000},
{"u": 5678, "p": 0},
{"u": 6789, "p": 2, "c": 14168154},
],
}
(event,) = parse_events(session, "/orca_presence", data)
la = datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc)
assert event == Presence(
statuses={
"1234": ActiveStatus(active=True),
"2345": ActiveStatus(active=True, last_active=la),
"3456": ActiveStatus(active=True),
"4567": ActiveStatus(active=False, last_active=la),
"5678": ActiveStatus(active=False),
"6789": ActiveStatus(active=True),
},
full=True,
)

View File

@@ -0,0 +1,459 @@
import pytest
import datetime
import fbchat
from fbchat import Image, UnsentMessage, ShareAttachment
from fbchat._models._message import graphql_to_extensible_attachment
def test_parse_unsent_message():
data = {
"legacy_attachment_id": "ee.mid.$xyz",
"story_attachment": {
"description": {"text": "You removed a message"},
"media": None,
"source": None,
"style_list": ["globally_deleted_message_placeholder", "fallback"],
"title_with_entities": {"text": ""},
"properties": [],
"url": None,
"deduplication_key": "deadbeef123",
"action_links": [],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": None,
"subattachments": [],
},
"genie_attachment": {"genie_message": None},
}
assert UnsentMessage(id="ee.mid.$xyz") == graphql_to_extensible_attachment(data)
def test_share_from_graphql_minimal():
data = {
"target": {},
"url": "a.com",
"title_with_entities": {"text": "a.com"},
"subattachments": [],
}
assert ShareAttachment(
url="a.com", original_url="a.com", title="a.com"
) == ShareAttachment._from_graphql(data)
def test_share_from_graphql_link():
data = {
"description": {"text": ""},
"media": {
"animated_image": None,
"image": None,
"playable_duration_in_ms": 0,
"is_playable": False,
"playable_url": None,
},
"source": {"text": "a.com"},
"style_list": ["share", "fallback"],
"title_with_entities": {"text": "a.com"},
"properties": [],
"url": "http://l.facebook.com/l.php?u=http%3A%2F%2Fa.com%2F&h=def&s=1",
"deduplication_key": "ee.mid.$xyz",
"action_links": [{"title": "About this website", "url": None}],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {"__typename": "ExternalUrl"},
"subattachments": [],
}
assert ShareAttachment(
author=None,
url="http://l.facebook.com/l.php?u=http%3A%2F%2Fa.com%2F&h=def&s=1",
original_url="http://a.com/",
title="a.com",
description="",
source="a.com",
image=None,
original_image_url=None,
attachments=[],
id="ee.mid.$xyz",
) == ShareAttachment._from_graphql(data)
def test_share_from_graphql_link_with_image():
data = {
"description": {
"text": (
"Create an account or log in to Facebook."
" Connect with friends, family and other people you know."
" Share photos and videos, send messages and get updates."
)
},
"media": {
"animated_image": None,
"image": {
"uri": "https://www.facebook.com/rsrc.php/v3/x.png",
"height": 325,
"width": 325,
},
"playable_duration_in_ms": 0,
"is_playable": False,
"playable_url": None,
},
"source": None,
"style_list": ["share", "fallback"],
"title_with_entities": {"text": "Facebook log in or sign up"},
"properties": [],
"url": "http://facebook.com/",
"deduplication_key": "deadbeef123",
"action_links": [],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {"__typename": "ExternalUrl"},
"subattachments": [],
}
assert ShareAttachment(
author=None,
url="http://facebook.com/",
original_url="http://facebook.com/",
title="Facebook log in or sign up",
description=(
"Create an account or log in to Facebook."
" Connect with friends, family and other people you know."
" Share photos and videos, send messages and get updates."
),
source=None,
image=Image(
url="https://www.facebook.com/rsrc.php/v3/x.png", width=325, height=325
),
original_image_url="https://www.facebook.com/rsrc.php/v3/x.png",
attachments=[],
id="deadbeef123",
) == ShareAttachment._from_graphql(data)
def test_share_from_graphql_video():
data = {
"description": {
"text": (
"Rick Astley's official music video for “Never Gonna Give You Up”"
" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD"
" Subscribe to the official Rick As..."
)
},
"media": {
"animated_image": None,
"image": {
"uri": (
"https://external-arn2-1.xx.fbcdn.net/safe_image.php?d=xyz123"
"&w=960&h=540&url=https%3A%2F%2Fi.ytimg.com%2Fvi%2FdQw4w9WgXcQ"
"%2Fmaxresdefault.jpg&sx=0&sy=0&sw=1280&sh=720&_nc_hash=abc123"
),
"height": 540,
"width": 960,
},
"playable_duration_in_ms": 0,
"is_playable": True,
"playable_url": "https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1",
},
"source": {"text": "youtube.com"},
"style_list": ["share", "fallback"],
"title_with_entities": {
"text": "Rick Astley - Never Gonna Give You Up (Video)"
},
"properties": [
{"key": "width", "value": {"text": "1280"}},
{"key": "height", "value": {"text": "720"}},
],
"url": "https://l.facebook.com/l.php?u=https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ",
"deduplication_key": "ee.mid.$gAAT4Sw1WSGhzQ9uRWVtEpZHZ8ZPV",
"action_links": [{"title": "About this website", "url": None}],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {"__typename": "ExternalUrl"},
"subattachments": [],
}
assert ShareAttachment(
author=None,
url="https://l.facebook.com/l.php?u=https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ",
original_url="https://youtu.be/dQw4w9WgXcQ",
title="Rick Astley - Never Gonna Give You Up (Video)",
description=(
"Rick Astley's official music video for “Never Gonna Give You Up”"
" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD"
" Subscribe to the official Rick As..."
),
source="youtube.com",
image=Image(
url="https://external-arn2-1.xx.fbcdn.net/safe_image.php?d=xyz123"
"&w=960&h=540&url=https%3A%2F%2Fi.ytimg.com%2Fvi%2FdQw4w9WgXcQ"
"%2Fmaxresdefault.jpg&sx=0&sy=0&sw=1280&sh=720&_nc_hash=abc123",
width=960,
height=540,
),
original_image_url="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
attachments=[],
id="ee.mid.$gAAT4Sw1WSGhzQ9uRWVtEpZHZ8ZPV",
) == ShareAttachment._from_graphql(data)
def test_share_with_image_subattachment():
data = {
"description": {"text": "Abc"},
"media": {
"animated_image": None,
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
"height": 960,
"width": 720,
},
"playable_duration_in_ms": 0,
"is_playable": False,
"playable_url": None,
},
"source": {"text": "Def"},
"style_list": ["attached_story", "fallback"],
"title_with_entities": {"text": ""},
"properties": [],
"url": "https://www.facebook.com/groups/11223344/permalink/1234/",
"deduplication_key": "deadbeef123",
"action_links": [
{"title": None, "url": None},
{"title": None, "url": "https://www.facebook.com/groups/11223344/"},
{
"title": "Report Post to Admin",
"url": "https://www.facebook.com/groups/11223344/members/",
},
],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {
"__typename": "Story",
"title": None,
"description": {"text": "Abc"},
"actors": [
{
"__typename": "User",
"name": "Def",
"id": "1111",
"short_name": "Def",
"url": "https://www.facebook.com/some-user",
"profile_picture": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-1/c123.123.123.123a/s50x50/img.jpg",
"height": 50,
"width": 50,
},
}
],
"to": {
"__typename": "Group",
"name": "Some group",
"url": "https://www.facebook.com/groups/11223344/",
},
"attachments": [
{
"url": "https://www.facebook.com/photo.php?fbid=4321&set=gm.1234&type=3",
"media": {
"is_playable": False,
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
"height": 960,
"width": 720,
},
},
}
],
"attached_story": None,
},
"subattachments": [
{
"description": {"text": "Abc"},
"media": {
"animated_image": None,
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
"height": 960,
"width": 720,
},
"playable_duration_in_ms": 0,
"is_playable": False,
"playable_url": None,
},
"source": None,
"style_list": ["photo", "games_app", "fallback"],
"title_with_entities": {"text": ""},
"properties": [
{"key": "photoset_reference_token", "value": {"text": "gm.1234"}},
{"key": "layout_x", "value": {"text": "0"}},
{"key": "layout_y", "value": {"text": "0"}},
{"key": "layout_w", "value": {"text": "0"}},
{"key": "layout_h", "value": {"text": "0"}},
],
"url": "https://www.facebook.com/photo.php?fbid=4321&set=gm.1234&type=3",
"deduplication_key": "deadbeef456",
"action_links": [],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {"__typename": "Photo"},
}
],
}
assert ShareAttachment(
author="1111",
url="https://www.facebook.com/groups/11223344/permalink/1234/",
original_url="https://www.facebook.com/groups/11223344/permalink/1234/",
title="",
description="Abc",
source="Def",
image=Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
width=720,
height=960,
),
original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
attachments=[None],
id="deadbeef123",
) == ShareAttachment._from_graphql(data)
def test_share_with_video_subattachment():
data = {
"description": {"text": "Abc"},
"media": {
"animated_image": None,
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
"height": 540,
"width": 960,
},
"playable_duration_in_ms": 24469,
"is_playable": True,
"playable_url": "https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
},
"source": {"text": "Def"},
"style_list": ["attached_story", "fallback"],
"title_with_entities": {"text": ""},
"properties": [],
"url": "https://www.facebook.com/groups/11223344/permalink/1234/",
"deduplication_key": "deadbeef123",
"action_links": [
{"title": None, "url": None},
{"title": None, "url": "https://www.facebook.com/groups/11223344/"},
{"title": None, "url": None},
{"title": "A watch party is currently playing this video.", "url": None},
],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {
"__typename": "Story",
"title": None,
"description": {"text": "Abc"},
"actors": [
{
"__typename": "User",
"name": "Def",
"id": "1111",
"short_name": "Def",
"url": "https://www.facebook.com/some-user",
"profile_picture": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-1/c1.0.50.50a/p50x50/profile.jpg",
"height": 50,
"width": 50,
},
}
],
"to": {
"__typename": "Group",
"name": "Some group",
"url": "https://www.facebook.com/groups/11223344/",
},
"attachments": [
{
"url": "https://www.facebook.com/some-user/videos/2222/",
"media": {
"is_playable": True,
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
"height": 540,
"width": 960,
},
},
}
],
"attached_story": None,
},
"subattachments": [
{
"description": None,
"media": {
"animated_image": None,
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
"height": 540,
"width": 960,
},
"playable_duration_in_ms": 24469,
"is_playable": True,
"playable_url": "https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
},
"source": None,
"style_list": [
"video_autoplay",
"video_inline",
"video",
"games_app",
"fallback",
],
"title_with_entities": {"text": ""},
"properties": [
{
"key": "can_autoplay_result",
"value": {"text": "ugc_default_allowed"},
}
],
"url": "https://www.facebook.com/some-user/videos/2222/",
"deduplication_key": "deadbeef456",
"action_links": [],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {
"__typename": "Video",
"video_id": "2222",
"video_messenger_cta_payload": None,
},
}
],
}
assert ShareAttachment(
author="1111",
url="https://www.facebook.com/groups/11223344/permalink/1234/",
original_url="https://www.facebook.com/groups/11223344/permalink/1234/",
title="",
description="Abc",
source="Def",
image=Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
width=960,
height=540,
),
original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
attachments=[
fbchat.VideoAttachment(
id="2222",
duration=datetime.timedelta(seconds=24, microseconds=469000),
preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
previews={
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
width=960,
height=540,
)
},
)
],
id="deadbeef123",
) == ShareAttachment._from_graphql(data)

358
tests/models/test_file.py Normal file
View File

@@ -0,0 +1,358 @@
import datetime
import fbchat
from fbchat import (
Image,
FileAttachment,
AudioAttachment,
ImageAttachment,
VideoAttachment,
)
from fbchat._models._file import graphql_to_attachment, graphql_to_subattachment
def test_imageattachment_from_list():
data = {
"__typename": "MessageImage",
"id": "bWVzc2...",
"legacy_attachment_id": "1234",
"image": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg"},
"image1": {
"height": 463,
"width": 960,
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
},
"image2": {
"height": 988,
"width": 2048,
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg",
},
"original_dimensions": {"x": 2833, "y": 1367},
"photo_encodings": [],
}
assert ImageAttachment(
id="1234",
width=2833,
height=1367,
previews={
Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg"),
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
width=960,
height=463,
),
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg",
width=2048,
height=988,
),
},
) == ImageAttachment._from_list(data)
def test_videoattachment_from_list():
data = {
"__typename": "MessageVideo",
"id": "bWVzc2...",
"legacy_attachment_id": "1234",
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg"
},
"image1": {
"height": 368,
"width": 640,
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg",
},
"image2": {
"height": 368,
"width": 640,
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg",
},
"original_dimensions": {"x": 640, "y": 368},
}
assert VideoAttachment(
id="1234",
width=640,
height=368,
previews={
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg"
),
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg",
width=640,
height=368,
),
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg",
width=640,
height=368,
),
},
) == VideoAttachment._from_list(data)
def test_graphql_to_attachment_empty():
assert fbchat.Attachment() == graphql_to_attachment({"__typename": "Unknown"})
def test_graphql_to_attachment_simple():
data = {"__typename": "Unknown", "legacy_attachment_id": "1234"}
assert fbchat.Attachment(id="1234") == graphql_to_attachment(data)
def test_graphql_to_attachment_file():
data = {
"__typename": "MessageFile",
"attribution_app": None,
"attribution_metadata": None,
"filename": "file.txt",
"url": "https://l.facebook.com/l.php?u=https%3A%2F%2Fcdn.fbsbx.com%2Fv%2Ffile.txt&h=AT1...&s=1",
"content_type": "attach:text",
"is_malicious": False,
"message_file_fbid": "1234",
"url_shimhash": "AT0...",
"url_skipshim": True,
}
assert FileAttachment(
id="1234",
url="https://l.facebook.com/l.php?u=https%3A%2F%2Fcdn.fbsbx.com%2Fv%2Ffile.txt&h=AT1...&s=1",
size=None,
name="file.txt",
is_malicious=False,
) == graphql_to_attachment(data)
def test_graphql_to_attachment_audio():
data = {
"__typename": "MessageAudio",
"attribution_app": None,
"attribution_metadata": None,
"filename": "audio.mp3",
"playable_url": "https://cdn.fbsbx.com/v/audio.mp3?dl=1",
"playable_duration_in_ms": 27745,
"is_voicemail": False,
"audio_type": "FILE_ATTACHMENT",
"url_shimhash": "AT0...",
"url_skipshim": True,
}
assert AudioAttachment(
id=None,
filename="audio.mp3",
url="https://cdn.fbsbx.com/v/audio.mp3?dl=1",
duration=datetime.timedelta(seconds=27, microseconds=745000),
audio_type="FILE_ATTACHMENT",
) == graphql_to_attachment(data)
def test_graphql_to_attachment_image1():
data = {
"__typename": "MessageImage",
"attribution_app": None,
"attribution_metadata": None,
"filename": "image-1234",
"preview": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/1.png",
"height": 128,
"width": 128,
},
"large_preview": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/1.png",
"height": 128,
"width": 128,
},
"thumbnail": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/2.png"},
"photo_encodings": [],
"legacy_attachment_id": "1234",
"original_dimensions": {"x": 128, "y": 128},
"original_extension": "png",
"render_as_sticker": False,
"blurred_image_uri": None,
}
assert ImageAttachment(
id="1234",
original_extension="png",
width=None,
height=None,
is_animated=False,
previews={
Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/2.png"),
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/1.png",
width=128,
height=128,
),
},
) == graphql_to_attachment(data)
def test_graphql_to_attachment_image2():
data = {
"__typename": "MessageAnimatedImage",
"attribution_app": None,
"attribution_metadata": None,
"filename": "gif-1234",
"animated_image": {
"uri": "https://cdn.fbsbx.com/v/1.gif",
"height": 128,
"width": 128,
},
"legacy_attachment_id": "1234",
"preview_image": {
"uri": "https://cdn.fbsbx.com/v/1.gif",
"height": 128,
"width": 128,
},
"original_dimensions": {"x": 128, "y": 128},
}
assert ImageAttachment(
id="1234",
original_extension="gif",
width=None,
height=None,
is_animated=True,
previews={Image(url="https://cdn.fbsbx.com/v/1.gif", width=128, height=128)},
) == graphql_to_attachment(data)
def test_graphql_to_attachment_video():
data = {
"__typename": "MessageVideo",
"attribution_app": None,
"attribution_metadata": None,
"filename": "video-4321.mp4",
"playable_url": "https://video-arn2-1.xx.fbcdn.net/v/video-4321.mp4",
"chat_image": {
"height": 96,
"width": 168,
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg",
},
"legacy_attachment_id": "1234",
"video_type": "FILE_ATTACHMENT",
"original_dimensions": {"x": 640, "y": 368},
"playable_duration_in_ms": 6000,
"large_image": {
"height": 368,
"width": 640,
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
},
"inbox_image": {
"height": 260,
"width": 452,
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg",
},
}
assert VideoAttachment(
id="1234",
width=None,
height=None,
duration=datetime.timedelta(seconds=6),
preview_url="https://video-arn2-1.xx.fbcdn.net/v/video-4321.mp4",
previews={
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg",
width=168,
height=96,
),
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg",
width=452,
height=260,
),
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg",
width=640,
height=368,
),
},
) == graphql_to_attachment(data)
def test_graphql_to_subattachment_empty():
assert None is graphql_to_subattachment({})
def test_graphql_to_subattachment_image():
data = {
"description": {"text": "Abc"},
"media": {
"animated_image": None,
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg",
"height": 960,
"width": 720,
},
"playable_duration_in_ms": 0,
"is_playable": False,
"playable_url": None,
},
"source": None,
"style_list": ["photo", "games_app", "fallback"],
"title_with_entities": {"text": ""},
"properties": [
{"key": "photoset_reference_token", "value": {"text": "gm.4321"}},
{"key": "layout_x", "value": {"text": "0"}},
{"key": "layout_y", "value": {"text": "0"}},
{"key": "layout_w", "value": {"text": "0"}},
{"key": "layout_h", "value": {"text": "0"}},
],
"url": "https://www.facebook.com/photo.php?fbid=1234&set=gm.4321&type=3",
"deduplication_key": "8334...",
"action_links": [],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {"__typename": "Photo"},
}
assert None is graphql_to_subattachment(data)
def test_graphql_to_subattachment_video():
data = {
"description": None,
"media": {
"animated_image": None,
"image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
"height": 540,
"width": 960,
},
"playable_duration_in_ms": 24469,
"is_playable": True,
"playable_url": "https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
},
"source": None,
"style_list": [
"video_autoplay",
"video_inline",
"video",
"games_app",
"fallback",
],
"title_with_entities": {"text": ""},
"properties": [
{"key": "can_autoplay_result", "value": {"text": "ugc_default_allowed"}}
],
"url": "https://www.facebook.com/some-username/videos/1234/",
"deduplication_key": "ddb7...",
"action_links": [],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {
"__typename": "Video",
"video_id": "1234",
"video_messenger_cta_payload": None,
},
}
assert VideoAttachment(
id="1234",
duration=datetime.timedelta(seconds=24, microseconds=469000),
preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4",
previews={
Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg",
width=960,
height=540,
)
},
) == graphql_to_subattachment(data)

View File

@@ -0,0 +1,96 @@
import pytest
import datetime
import fbchat
from fbchat import Image, LocationAttachment, LiveLocationAttachment
def test_location_attachment_from_graphql():
data = {
"description": {"text": ""},
"media": {
"animated_image": None,
"image": {
"uri": "https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en",
"height": 280,
"width": 545,
},
"playable_duration_in_ms": 0,
"is_playable": False,
"playable_url": None,
},
"source": None,
"style_list": ["message_location", "fallback"],
"title_with_entities": {"text": "Your location"},
"properties": [
{"key": "width", "value": {"text": "545"}},
{"key": "height", "value": {"text": "280"}},
],
"url": "https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1",
"deduplication_key": "400828513928715",
"action_links": [],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"xma_layout_info": None,
"target": {"__typename": "MessageLocation"},
"subattachments": [],
}
assert LocationAttachment(
id=400828513928715,
latitude=55.4,
longitude=12.4322,
image=Image(
url="https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en",
width=545,
height=280,
),
url="https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1",
) == LocationAttachment._from_graphql(data)
@pytest.mark.skip(reason="need to gather test data")
def test_live_location_from_pull():
data = ...
assert LiveLocationAttachment(...) == LiveLocationAttachment._from_pull(data)
def test_live_location_from_graphql_expired():
data = {
"description": {"text": "Last update 4 Jan"},
"media": None,
"source": None,
"style_list": ["message_live_location", "fallback"],
"title_with_entities": {"text": "Location-sharing ended"},
"properties": [],
"url": "https://www.facebook.com/",
"deduplication_key": "2254535444791641",
"action_links": [],
"messaging_attribution": None,
"messenger_call_to_actions": [],
"target": {
"__typename": "MessageLiveLocation",
"live_location_id": "2254535444791641",
"is_expired": True,
"expiration_time": 1546626345,
"sender": {"id": "100007056224713"},
"coordinate": None,
"location_title": None,
"sender_destination": None,
"stop_reason": "CANCELED",
},
"subattachments": [],
}
assert LiveLocationAttachment(
id=2254535444791641,
name="Location-sharing ended",
expires_at=datetime.datetime(
2019, 1, 4, 18, 25, 45, tzinfo=datetime.timezone.utc
),
is_expired=True,
url="https://www.facebook.com/",
) == LiveLocationAttachment._from_graphql(data)
@pytest.mark.skip(reason="need to gather test data")
def test_live_location_from_graphql():
data = ...
assert LiveLocationAttachment(...) == LiveLocationAttachment._from_graphql(data)

View File

@@ -0,0 +1,118 @@
import pytest
import fbchat
from fbchat import EmojiSize, Mention, Message, MessageData
from fbchat._models._message import graphql_to_extensible_attachment
@pytest.mark.parametrize(
"tags,size",
[
(None, None),
(["hot_emoji_size:unknown"], None),
(["bunch", "of:different", "tags:large", "hot_emoji_size:s"], EmojiSize.SMALL),
(["hot_emoji_size:s"], EmojiSize.SMALL),
(["hot_emoji_size:m"], EmojiSize.MEDIUM),
(["hot_emoji_size:l"], EmojiSize.LARGE),
(["hot_emoji_size:small"], EmojiSize.SMALL),
(["hot_emoji_size:medium"], EmojiSize.MEDIUM),
(["hot_emoji_size:large"], EmojiSize.LARGE),
],
)
def test_emojisize_from_tags(tags, size):
assert size is EmojiSize._from_tags(tags)
def test_graphql_to_extensible_attachment_empty():
assert None is graphql_to_extensible_attachment({})
@pytest.mark.parametrize(
"obj,type_",
[
# UnsentMessage testing is done in test_attachment.py
(fbchat.LocationAttachment, "MessageLocation"),
(fbchat.LiveLocationAttachment, "MessageLiveLocation"),
(fbchat.ShareAttachment, "ExternalUrl"),
(fbchat.ShareAttachment, "Story"),
],
)
def test_graphql_to_extensible_attachment_dispatch(monkeypatch, obj, type_):
monkeypatch.setattr(obj, "_from_graphql", lambda data: True)
data = {"story_attachment": {"target": {"__typename": type_}}}
assert graphql_to_extensible_attachment(data)
def test_mention_from_range():
data = {"length": 17, "offset": 0, "entity": {"__typename": "User", "id": "1234"}}
assert Mention(thread_id="1234", offset=0, length=17) == Mention._from_range(data)
data = {
"length": 2,
"offset": 10,
"entity": {"__typename": "MessengerViewer1To1Thread"},
}
assert Mention(thread_id=None, offset=10, length=2) == Mention._from_range(data)
data = {
"length": 5,
"offset": 21,
"entity": {"__typename": "MessengerViewerGroupThread"},
}
assert Mention(thread_id=None, offset=21, length=5) == Mention._from_range(data)
def test_mention_to_send_data():
assert {
"profile_xmd[0][id]": "1234",
"profile_xmd[0][length]": 7,
"profile_xmd[0][offset]": 4,
"profile_xmd[0][type]": "p",
} == Mention(thread_id="1234", offset=4, length=7)._to_send_data(0)
assert {
"profile_xmd[1][id]": "4321",
"profile_xmd[1][length]": 7,
"profile_xmd[1][offset]": 24,
"profile_xmd[1][type]": "p",
} == Mention(thread_id="4321", offset=24, length=7)._to_send_data(1)
def test_message_format_mentions():
expected = (
"Hey 'Peter'! My name is Michael",
[
Mention(thread_id="1234", offset=4, length=7),
Mention(thread_id="4321", offset=24, length=7),
],
)
assert expected == Message.format_mentions(
"Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael")
)
assert expected == Message.format_mentions(
"Hey {p!r}! My name is {}", ("4321", "Michael"), p=("1234", "Peter")
)
def test_message_get_forwarded_from_tags():
assert not MessageData._get_forwarded_from_tags(None)
assert not MessageData._get_forwarded_from_tags(["hot_emoji_size:unknown"])
assert MessageData._get_forwarded_from_tags(
["attachment:photo", "inbox", "sent", "source:chat:forward", "tq"]
)
@pytest.mark.skip(reason="need to be added")
def test_message_to_send_data_quick_replies():
raise NotImplementedError
@pytest.mark.skip(reason="need to gather test data")
def test_message_from_graphql():
pass
@pytest.mark.skip(reason="need to gather test data")
def test_message_from_reply():
pass
@pytest.mark.skip(reason="need to gather test data")
def test_message_from_pull():
pass

155
tests/models/test_plan.py Normal file
View File

@@ -0,0 +1,155 @@
import datetime
from fbchat import GuestStatus, PlanData
def test_plan_properties(session):
plan = PlanData(
session=session,
id="1234567890",
time=...,
title=...,
guests={
"1234": GuestStatus.INVITED,
"2345": GuestStatus.INVITED,
"3456": GuestStatus.GOING,
"4567": GuestStatus.DECLINED,
},
)
assert set(plan.invited) == {"1234", "2345"}
assert plan.going == ["3456"]
assert plan.declined == ["4567"]
def test_plan_from_pull(session):
data = {
"event_timezone": "",
"event_creator_id": "1234",
"event_id": "1111",
"event_type": "EVENT",
"event_track_rsvp": "1",
"event_title": "abc",
"event_time": "1500000000",
"event_seconds_to_notify_before": "3600",
"guest_state_list": (
'[{"guest_list_state":"INVITED","node":{"id":"1234"}},'
'{"guest_list_state":"INVITED","node":{"id":"2356"}},'
'{"guest_list_state":"DECLINED","node":{"id":"3456"}},'
'{"guest_list_state":"GOING","node":{"id":"4567"}}]'
),
}
assert PlanData(
session=session,
id="1111",
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc",
author_id="1234",
guests={
"1234": GuestStatus.INVITED,
"2356": GuestStatus.INVITED,
"3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING,
},
) == PlanData._from_pull(session, data)
def test_plan_from_fetch(session):
data = {
"message_thread_id": 123456789,
"event_time": 1500000000,
"creator_id": 1234,
"event_time_updated_time": 1450000000,
"title": "abc",
"track_rsvp": 1,
"event_type": "EVENT",
"status": "created",
"message_id": "mid.xyz",
"seconds_to_notify_before": 3600,
"event_time_source": "user",
"repeat_mode": "once",
"creation_time": 1400000000,
"location_id": 0,
"location_name": None,
"latitude": "",
"longitude": "",
"event_id": 0,
"trigger_message_id": "",
"note": "",
"timezone_id": 0,
"end_time": 0,
"list_id": 0,
"payload_id": 0,
"cu_app": "",
"location_sharing_subtype": "",
"reminder_notif_param": [],
"workplace_meeting_id": "",
"genie_fbid": 0,
"galaxy": "",
"oid": 1111,
"type": 8128,
"is_active": True,
"location_address": None,
"event_members": {
"1234": "INVITED",
"2356": "INVITED",
"3456": "DECLINED",
"4567": "GOING",
},
}
assert PlanData(
session=session,
id=1111,
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc",
location="",
location_id="",
author_id=1234,
guests={
"1234": GuestStatus.INVITED,
"2356": GuestStatus.INVITED,
"3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING,
},
) == PlanData._from_fetch(session, data)
def test_plan_from_graphql(session):
data = {
"id": "1111",
"lightweight_event_creator": {"id": "1234"},
"time": 1500000000,
"lightweight_event_type": "EVENT",
"location_name": None,
"location_coordinates": None,
"location_page": None,
"lightweight_event_status": "CREATED",
"note": "",
"repeat_mode": "ONCE",
"event_title": "abc",
"trigger_message": None,
"seconds_to_notify_before": 3600,
"allows_rsvp": True,
"related_event": None,
"event_reminder_members": {
"edges": [
{"node": {"id": "1234"}, "guest_list_state": "INVITED"},
{"node": {"id": "2356"}, "guest_list_state": "INVITED"},
{"node": {"id": "3456"}, "guest_list_state": "DECLINED"},
{"node": {"id": "4567"}, "guest_list_state": "GOING"},
]
},
}
assert PlanData(
session=session,
time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc),
title="abc",
location="",
location_id="",
id="1111",
author_id="1234",
guests={
"1234": GuestStatus.INVITED,
"2356": GuestStatus.INVITED,
"3456": GuestStatus.DECLINED,
"4567": GuestStatus.GOING,
},
) == PlanData._from_graphql(session, data)

94
tests/models/test_poll.py Normal file
View File

@@ -0,0 +1,94 @@
from fbchat import Poll, PollOption
def test_poll_option_from_graphql_unvoted():
data = {
"id": "123456789",
"text": "abc",
"total_count": 0,
"viewer_has_voted": "false",
"voters": [],
}
assert PollOption(
text="abc", vote=False, voters=[], votes_count=0, id="123456789"
) == PollOption._from_graphql(data)
def test_poll_option_from_graphql_voted():
data = {
"id": "123456789",
"text": "abc",
"total_count": 2,
"viewer_has_voted": "true",
"voters": ["1234", "2345"],
}
assert PollOption(
text="abc", vote=True, voters=["1234", "2345"], votes_count=2, id="123456789"
) == PollOption._from_graphql(data)
def test_poll_option_from_graphql_alternate_format():
# Format received when fetching poll options
data = {
"id": "123456789",
"text": "abc",
"viewer_has_voted": True,
"voters": {
"count": 2,
"edges": [{"node": {"id": "1234"}}, {"node": {"id": "2345"}}],
},
}
assert PollOption(
text="abc", vote=True, voters=["1234", "2345"], votes_count=2, id="123456789"
) == PollOption._from_graphql(data)
def test_poll_from_graphql(session):
data = {
"id": "123456789",
"text": "Some poll",
"total_count": 5,
"viewer_has_voted": "true",
"options": [
{
"id": "1111",
"text": "Abc",
"total_count": 1,
"viewer_has_voted": "true",
"voters": ["1234"],
},
{
"id": "2222",
"text": "Def",
"total_count": 2,
"viewer_has_voted": "false",
"voters": ["2345", "3456"],
},
{
"id": "3333",
"text": "Ghi",
"total_count": 0,
"viewer_has_voted": "false",
"voters": [],
},
],
}
assert Poll(
session=session,
question="Some poll",
options=[
PollOption(
text="Abc", vote=True, voters=["1234"], votes_count=1, id="1111"
),
PollOption(
text="Def",
vote=False,
voters=["2345", "3456"],
votes_count=2,
id="2222",
),
PollOption(text="Ghi", vote=False, voters=[], votes_count=0, id="3333"),
],
options_count=5,
id=123456789,
) == Poll._from_graphql(session, data)

View File

@@ -0,0 +1,49 @@
from fbchat import (
QuickReplyText,
QuickReplyLocation,
QuickReplyPhoneNumber,
QuickReplyEmail,
)
from fbchat._models._quick_reply import graphql_to_quick_reply
def test_parse_minimal():
data = {
"content_type": "text",
"payload": None,
"external_payload": None,
"data": None,
"title": "A",
"image_url": None,
}
assert QuickReplyText(title="A") == graphql_to_quick_reply(data)
data = {"content_type": "location"}
assert QuickReplyLocation() == graphql_to_quick_reply(data)
data = {"content_type": "user_phone_number"}
assert QuickReplyPhoneNumber() == graphql_to_quick_reply(data)
data = {"content_type": "user_email"}
assert QuickReplyEmail() == graphql_to_quick_reply(data)
def test_parse_text_full():
data = {
"content_type": "text",
"title": "A",
"payload": "Some payload",
"image_url": "https://example.com/image.jpg",
"data": None,
}
assert QuickReplyText(
payload="Some payload",
data=None,
is_response=False,
title="A",
image_url="https://example.com/image.jpg",
) == graphql_to_quick_reply(data)
def test_parse_with_is_response():
data = {"content_type": "text"}
assert QuickReplyText(is_response=True) == graphql_to_quick_reply(
data, is_response=True
)

View File

@@ -0,0 +1,91 @@
import pytest
import fbchat
from fbchat import Image, Sticker
def test_from_graphql_none():
assert None == Sticker._from_graphql(None)
def test_from_graphql_minimal():
assert Sticker(id=1) == Sticker._from_graphql({"id": 1})
def test_from_graphql_normal():
assert Sticker(
id="369239383222810",
pack="227877430692340",
is_animated=False,
frames_per_row=1,
frames_per_col=1,
frame_count=1,
frame_rate=83,
image=Image(
url="https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png",
width=274,
height=274,
),
label="Like, thumbs up",
) == Sticker._from_graphql(
{
"id": "369239383222810",
"pack": {"id": "227877430692340"},
"label": "Like, thumbs up",
"frame_count": 1,
"frame_rate": 83,
"frames_per_row": 1,
"frames_per_column": 1,
"sprite_image_2x": None,
"sprite_image": None,
"padded_sprite_image": None,
"padded_sprite_image_2x": None,
"url": "https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png",
"height": 274,
"width": 274,
}
)
def test_from_graphql_animated():
assert Sticker(
id="144885035685763",
pack="350357561732812",
is_animated=True,
medium_sprite_image="https://scontent-arn2-1.xx.fbcdn.net/v/redacted2.png",
large_sprite_image="https://scontent-arn2-1.fbcdn.net/v/redacted3.png",
frames_per_row=2,
frames_per_col=2,
frame_count=4,
frame_rate=142,
image=Image(
url="https://scontent-arn2-1.fbcdn.net/v/redacted1.png",
width=240,
height=293,
),
label="Love, cat with heart",
) == Sticker._from_graphql(
{
"id": "144885035685763",
"pack": {"id": "350357561732812"},
"label": "Love, cat with heart",
"frame_count": 4,
"frame_rate": 142,
"frames_per_row": 2,
"frames_per_column": 2,
"sprite_image_2x": {
"uri": "https://scontent-arn2-1.fbcdn.net/v/redacted3.png"
},
"sprite_image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/redacted2.png"
},
"padded_sprite_image": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/unused1.png"
},
"padded_sprite_image_2x": {
"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/unused2.png"
},
"url": "https://scontent-arn2-1.fbcdn.net/v/redacted1.png",
"height": 293,
"width": 240,
}
)

BIN
tests/resources/audio.mp3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,4 @@
{
"some": "data",
"in": "here"
}

Some files were not shown because too many files have changed in this diff Show More