Compare commits
886 Commits
v1.0.13
...
769b034d38
| Author | SHA1 | Date | |
|---|---|---|---|
| 769b034d38 | |||
| fd3d5f7301 | |||
| 2fa1b58336 | |||
| 9523350dc5 | |||
| 356db553b7 | |||
| 55712756d7 | |||
|
|
916a14062d | ||
|
|
43aa16c32d | ||
|
|
427ae6bc5e | ||
|
|
d650946531 | ||
|
|
8ac6dc4ae6 | ||
|
|
a6cf1d5c89 | ||
|
|
65b42e6532 | ||
|
|
8824a1c253 | ||
|
|
520258e339 | ||
|
|
435dfaf6d8 | ||
|
|
cf0e1e3a93 | ||
|
|
2319fc7c4a | ||
|
|
b35240bdda | ||
|
|
6141cc5a41 | ||
|
|
b1e438dae1 | ||
|
|
3c0f411be7 | ||
|
|
9ad0090b02 | ||
|
|
bec151a560 | ||
|
|
2087182ecf | ||
|
|
09627b71ae | ||
|
|
078bf9fc16 | ||
|
|
d33e36866d | ||
|
|
2a382ffaed | ||
|
|
18a3ffb90d | ||
|
|
db284cefdf | ||
|
|
d11f417caa | ||
|
|
3b71258f2c | ||
|
|
81584d328b | ||
|
|
7be2acad7d | ||
|
|
079d4093c4 | ||
|
|
cce947b18c | ||
|
|
2545a01450 | ||
|
|
5d763dfbce | ||
|
|
0981be42b9 | ||
|
|
93b71bf198 | ||
|
|
af3758c8a9 | ||
|
|
f64c487a2d | ||
|
|
11534604fe | ||
|
|
9990952fa6 | ||
|
|
7ee7361646 | ||
|
|
89c6af516c | ||
|
|
c27f599e37 | ||
|
|
ef95aed208 | ||
|
|
8aaed0c76a | ||
|
|
6dbcb8cc47 | ||
|
|
6660fd099d | ||
|
|
e6ec5c5194 | ||
|
|
13e0eb7fcf | ||
|
|
7bdacb91ba | ||
|
|
94c985cb10 | ||
|
|
0f4ee33d2a | ||
|
|
4df1d5e0d4 | ||
|
|
085bbba302 | ||
|
|
ae2bb41509 | ||
|
|
9c03c1035b | ||
|
|
987993701f | ||
|
|
f8e110f180 | ||
|
|
2da8369c70 | ||
|
|
588c93467e | ||
|
|
01effb34b4 | ||
|
|
2c8dfc02c2 | ||
|
|
064707ac23 | ||
|
|
eaacaaba8d | ||
|
|
2cb43ff0b0 | ||
|
|
16081fbb19 | ||
|
|
4015bed474 | ||
|
|
c71c1d37c2 | ||
|
|
1776c3aa45 | ||
|
|
a1fc235327 | ||
|
|
2aea401c79 | ||
|
|
c83836ceed | ||
|
|
3efeffe6dd | ||
|
|
45a71fd1a3 | ||
|
|
0d139cee73 | ||
|
|
89f90ef849 | ||
|
|
7019124d1f | ||
|
|
0fd58c52ea | ||
|
|
8277b22c5c | ||
|
|
55ef9979c3 | ||
|
|
3d3b0f9e91 | ||
|
|
05375d9b11 | ||
|
|
66fdd91953 | ||
|
|
9fc9aeac08 | ||
|
|
935947f212 | ||
|
|
41f367a61b | ||
|
|
03cc95e755 | ||
|
|
b6fd7e2cf2 | ||
|
|
1526266bf3 | ||
|
|
e666073b18 | ||
|
|
2644aa9b7a | ||
|
|
701fe8ffc8 | ||
|
|
6117049489 | ||
|
|
6344038bac | ||
|
|
316ffe5a52 | ||
|
|
f7788a47bc | ||
|
|
a4afc39c13 | ||
|
|
b9b4d57b25 | ||
|
|
74a98d7eb3 | ||
|
|
b4618739f3 | ||
|
|
9b75db898a | ||
|
|
01f8578dea | ||
|
|
0a6bf221e6 | ||
|
|
4abe5659ae | ||
|
|
22c6c82c0e | ||
|
|
9cc286a1b0 | ||
|
|
19c875c18a | ||
|
|
12bbc0058c | ||
|
|
0696ff9f4b | ||
|
|
e735823d37 | ||
|
|
dbc88bc4ed | ||
|
|
d2f8acb68f | ||
|
|
8b70fe8bfd | ||
|
|
9228ac698d | ||
|
|
c0425193d0 | ||
|
|
28791b2118 | ||
|
|
e25f53d9a9 | ||
|
|
8f25a3bae8 | ||
|
|
3cdd646c37 | ||
|
|
3445eccc32 | ||
|
|
9c81806b95 | ||
|
|
45303005b8 | ||
|
|
656281eacb | ||
|
|
2b45fdbc8a | ||
|
|
22dcf6d69a | ||
|
|
60cce0d112 | ||
|
|
117433da8a | ||
|
|
55182e21b6 | ||
|
|
e76c6179fb | ||
|
|
e4f2c6c403 | ||
|
|
3c35770eca | ||
|
|
7c7ac1f1f6 | ||
|
|
da18111ed0 | ||
|
|
5e09cb9cab | ||
|
|
3662fbd038 | ||
|
|
281ef4714f | ||
|
|
26f99d983e | ||
|
|
9dd760223e | ||
|
|
9f1c9c9697 | ||
|
|
c81e509eb0 | ||
|
|
8b6d9b16c6 | ||
|
|
3341f4a45c | ||
|
|
b00f748647 | ||
|
|
f2bf3756db | ||
|
|
c98fa40c42 | ||
|
|
333c879192 | ||
|
|
e53d10fd85 | ||
|
|
5214a2aed2 | ||
|
|
12c2059812 | ||
|
|
a1b3fd3ffa | ||
|
|
6b39e58eb8 | ||
|
|
6d6f779d26 | ||
|
|
483fdf43dc | ||
|
|
e039e88f80 | ||
|
|
2459a0251a | ||
|
|
c7ee45aaca | ||
|
|
22217c793c | ||
|
|
fbeee69ece | ||
|
|
c79cfd21b0 | ||
|
|
deda3b433d | ||
|
|
906e813378 | ||
|
|
a9eeacb5be | ||
|
|
b4009cc0e6 | ||
|
|
942c3e5b70 | ||
|
|
2ec0be9635 | ||
|
|
d8d044f091 | ||
|
|
f968e583e8 | ||
|
|
88ba9c55d2 | ||
|
|
6baa594538 | ||
|
|
0e0fce714a | ||
|
|
cf24c7e8c2 | ||
|
|
ded6039b69 | ||
|
|
6b4327fa69 | ||
|
|
53e4669fc1 | ||
|
|
4dea10d5de | ||
|
|
bd2b39c27a | ||
|
|
e9864208ac | ||
|
|
f3b1d10d85 | ||
|
|
13aa1f5e5a | ||
|
|
aeca4865ae | ||
|
|
152f20027a | ||
|
|
4199439e07 | ||
|
|
64f55a572e | ||
|
|
a26554b4d6 | ||
|
|
0531a9e482 | ||
|
|
a5abb05ab3 | ||
|
|
45c0a4772d | ||
|
|
a36ff5ee6e | ||
|
|
78949e8ad5 | ||
|
|
06b7e14c31 | ||
|
|
41f1007936 | ||
|
|
092573fcbb | ||
|
|
881aa9adce | ||
|
|
4714be5697 | ||
|
|
cb7f4a72d7 | ||
|
|
fb63ff0db8 | ||
|
|
c5f447e20b | ||
|
|
b4d3769fd5 | ||
|
|
b199d597b2 | ||
|
|
debfb37a47 | ||
|
|
67fd6ffdf6 | ||
|
|
e57265016e | ||
|
|
cf4c22898c | ||
|
|
3bb99541e7 | ||
|
|
8c367af0ff | ||
|
|
e1c5e5e417 | ||
|
|
bc1e3edf17 | ||
|
|
e488f4a7da | ||
|
|
afad38d8e1 | ||
|
|
e9804d4184 | ||
|
|
a1b80a7abb | ||
|
|
803bfa7084 | ||
|
|
d1cb866b44 | ||
|
|
a298e0cf16 | ||
|
|
766b0125fb | ||
|
|
998fa43fb2 | ||
|
|
ecc6edac5a | ||
|
|
ea518ba4c9 | ||
|
|
49d5891bf5 | ||
|
|
5fd7ef5191 | ||
|
|
ffdf4222bf | ||
|
|
a97ef67411 | ||
|
|
aea4fea5a2 | ||
|
|
6c82e4d966 | ||
|
|
d1fbf0ba0a | ||
|
|
aaf26691d6 | ||
|
|
1f96c624e7 | ||
|
|
a7b08fefe4 | ||
|
|
91d4055545 | ||
|
|
523c320c08 | ||
|
|
27ae1c9f88 | ||
|
|
b03d0ae3b7 | ||
|
|
637ea97ffe | ||
|
|
074c271fb8 | ||
|
|
e348425204 | ||
|
|
b8f83610e7 | ||
|
|
41a445a989 | ||
|
|
80c7fff571 | ||
|
|
e2d98356ad | ||
|
|
a8412ea3d8 | ||
|
|
71177d8bf9 | ||
|
|
5019aac6b7 | ||
|
|
0c305f621a | ||
|
|
ef73bb27aa | ||
|
|
bd499c1ea2 | ||
|
|
24c4b10012 | ||
|
|
648cbb4999 | ||
|
|
ef5c86c427 | ||
|
|
5e0b80cada | ||
|
|
9898e8cd19 | ||
|
|
77d9b25bf0 | ||
|
|
e757e51a4e | ||
|
|
ce8711ba65 | ||
|
|
bdd7f69a66 | ||
|
|
d06ff7078a | ||
|
|
7416c8b7fc | ||
|
|
fc7cc4ca38 | ||
|
|
614e5ad4bb | ||
|
|
8d8ef6bbc9 | ||
|
|
5aed7b0abc | ||
|
|
856c1ffe0e | ||
|
|
650112a592 | ||
|
|
b5a37e35c6 | ||
|
|
91cf4589a5 | ||
|
|
4155775305 | ||
|
|
7c758501fc | ||
|
|
c70a39c568 | ||
|
|
2e88bd49d4 | ||
|
|
813219cd9c | ||
|
|
bb1f7d9294 | ||
|
|
6bffb66b5e | ||
|
|
72ab8695f1 | ||
|
|
47bdb84957 | ||
|
|
24cf4047b7 | ||
|
|
2e53963398 | ||
|
|
61842b199f | ||
|
|
aef64e5c29 | ||
|
|
6d13937c4a | ||
|
|
4b34a063e8 | ||
|
|
ba088d45a7 | ||
|
|
d12f9fd645 | ||
|
|
a6a3768a38 | ||
|
|
3d28c958d3 | ||
|
|
6b68916d74 | ||
|
|
8052b818de | ||
|
|
da4ed73ec6 | ||
|
|
62c9512734 | ||
|
|
d3a0ffc478 | ||
|
|
d84ad487ee | ||
|
|
01b80b300e | ||
|
|
66505f8f41 | ||
|
|
75378bb709 | ||
|
|
6fb6e707ba | ||
|
|
330473a092 | ||
|
|
5ee93b760a | ||
|
|
7911c2ebae | ||
|
|
3c00d66ccf | ||
|
|
12e752e681 | ||
|
|
1f342d0c71 | ||
|
|
5e86d4a48a | ||
|
|
0838f84859 | ||
|
|
abc938eacd | ||
|
|
4d13cd2c0b | ||
|
|
8f8971c706 | ||
|
|
2703d9513a | ||
|
|
3dce83de93 | ||
|
|
ef8e7d4251 | ||
|
|
a131e1ae73 | ||
|
|
84a86bd7bd | ||
|
|
adfb5886c9 | ||
|
|
8d237ea4ef | ||
|
|
513bc6eadf | ||
|
|
856962af63 | ||
|
|
128efe7fba | ||
|
|
7c68a29181 | ||
|
|
2f4e3f2bb1 | ||
|
|
0389b838bc | ||
|
|
441f53e382 | ||
|
|
83c45dcf40 | ||
|
|
cc9d81a39e | ||
|
|
edf14cfd84 | ||
|
|
ee79969eda | ||
|
|
dbb20b1fdc | ||
|
|
beee209249 | ||
|
|
d6876ce13b | ||
|
|
ed05d16a31 | ||
|
|
3806f01d2f | ||
|
|
5b69ced1e8 | ||
|
|
6b07f1d8b9 | ||
|
|
700cf14a50 | ||
|
|
1b08243cd2 | ||
|
|
a0b978004c | ||
|
|
efc8776e70 | ||
|
|
915f9a3782 | ||
|
|
e136d77ade | ||
|
|
04aec15833 | ||
|
|
dd5e1024db | ||
|
|
31d13f8fae | ||
|
|
19b4d929e2 | ||
|
|
27e5d1baae | ||
|
|
3a0b9867bc | ||
|
|
a9c681818a | ||
|
|
d279c96dd5 | ||
|
|
d30589d1fa | ||
|
|
47c744e5e2 | ||
|
|
708869ea93 | ||
|
|
8b47bf3e5d | ||
|
|
a2930b4386 | ||
|
|
2dc93ed18b | ||
|
|
2bd08c8254 | ||
|
|
81278ed553 | ||
|
|
589cec66e1 | ||
|
|
281a20f56a | ||
|
|
ae8d205dbe | ||
|
|
1e6222f46a | ||
|
|
4f2a24848e | ||
|
|
e670c80971 | ||
|
|
ba7572eddd | ||
|
|
a5c6fac976 | ||
|
|
1293814c3a | ||
|
|
1b2aeb01ce | ||
|
|
cab8abd1a0 | ||
|
|
edda2386fb | ||
|
|
b0ad5f6097 | ||
|
|
6862bd7be3 | ||
|
|
bc551a63c2 | ||
|
|
c9f11b924d | ||
|
|
3236ea5b97 | ||
|
|
794696d327 | ||
|
|
7345de149a | ||
|
|
4fdf0bbc57 | ||
|
|
d17f741f97 | ||
|
|
4a898b3ff5 | ||
|
|
7f84ca8d0c | ||
|
|
c3a974a495 | ||
|
|
5b57d49a3e | ||
|
|
7af83c04c0 | ||
|
|
b5ba338f86 | ||
|
|
50bfeb92b2 | ||
|
|
8d41ea5bfd | ||
|
|
b10b14c8e9 | ||
|
|
144e81bd46 | ||
|
|
230c849b60 | ||
|
|
466f27a8c5 | ||
|
|
dc12e01fc7 | ||
|
|
d0e9a7f693 | ||
|
|
1ba21e03c6 | ||
|
|
bcc8b44bb5 | ||
|
|
b01b371c66 | ||
|
|
94a0f6b3df | ||
|
|
5df10ecc31 | ||
|
|
56786406ec | ||
|
|
a4268f36cf | ||
|
|
8e7afa2edf | ||
|
|
f07122d446 | ||
|
|
78c307780b | ||
|
|
ad705d544a | ||
|
|
77f28315c9 | ||
|
|
e0754031ad | ||
|
|
f97d36b41f | ||
|
|
bb2afe8e40 | ||
|
|
faa0383af3 | ||
|
|
e1e988272b | ||
|
|
b159f04a6b | ||
|
|
d91a7ea9e3 | ||
|
|
8056f3399e | ||
|
|
fd9aa7ee90 | ||
|
|
53c19f473b | ||
|
|
78b5f05729 | ||
|
|
f689376830 | ||
|
|
d244856b41 | ||
|
|
3cd0f3a9a7 | ||
|
|
f480d68b57 | ||
|
|
db2bda1f9b | ||
|
|
f834c01921 | ||
|
|
f945fa80b3 | ||
|
|
70faa86e34 | ||
|
|
61502ed32a | ||
|
|
bfca20bb12 | ||
|
|
0fd86d05a1 | ||
|
|
c688d64062 | ||
|
|
2f973f129d | ||
|
|
9b81365b0a | ||
|
|
a079797fca | ||
|
|
6ab298f6e8 | ||
|
|
a159999879 | ||
|
|
a71835a5b8 | ||
|
|
86a6e07804 | ||
|
|
73c6be1969 | ||
|
|
7db7868d2b | ||
|
|
18ec1f5680 | ||
|
|
8e65074b11 | ||
|
|
d720438aef | ||
|
|
ec0e3a91d1 | ||
|
|
48e7203ca6 | ||
|
|
4f76b79629 | ||
|
|
1eeae78a9f | ||
|
|
bc27f756ed | ||
|
|
6302d5fb8b | ||
|
|
24e238c425 | ||
|
|
070f57fcc4 | ||
|
|
a4ce45e9b0 | ||
|
|
a3efa7702a | ||
|
|
d7a5d00439 | ||
|
|
6636d49cc0 | ||
|
|
8e6ee4636e | ||
|
|
71f19dd3c7 | ||
|
|
e166b472c5 | ||
|
|
28c867a115 | ||
|
|
f20a04b2a0 | ||
|
|
1f961b2ca7 | ||
|
|
e579e0c767 | ||
|
|
6693ec9c36 | ||
|
|
53856a3622 | ||
|
|
0b99238676 | ||
|
|
cb2c68e25a | ||
|
|
fd5553a9f5 | ||
|
|
60ebbd87d8 | ||
|
|
3a5185fcc8 | ||
|
|
ce469d5e5a | ||
|
|
4f0f126e48 | ||
|
|
94c30a2440 | ||
|
|
1460b2f421 | ||
|
|
968223690e | ||
|
|
789d9d8ca1 | ||
|
|
2ce99a2c44 | ||
|
|
ee207e994f | ||
|
|
c374aca890 | ||
|
|
c28ca58537 | ||
|
|
0578ea2c3c | ||
|
|
e51ce99c1a | ||
|
|
3440039610 | ||
|
|
279f637c75 | ||
|
|
d940b64517 | ||
|
|
403870e39e | ||
|
|
0383d613e6 | ||
|
|
40e9825ee0 | ||
|
|
ab9ca94181 | ||
|
|
0f99a23af7 | ||
|
|
bc5163adaf | ||
|
|
0561718917 | ||
|
|
c1861627fb | ||
|
|
e5eccab871 | ||
|
|
27f76ba659 | ||
|
|
589117b9e7 | ||
|
|
80300cd160 | ||
|
|
76171408cc | ||
|
|
c1800a174f | ||
|
|
8ae8435940 | ||
|
|
f916cb3b53 | ||
|
|
929c2137bf | ||
|
|
98056e91c5 | ||
|
|
944a7248c3 | ||
|
|
caa2ecd0b7 | ||
|
|
dfc2d0652f | ||
|
|
8d25540445 | ||
|
|
6ea174bfd4 | ||
|
|
56e43aec0e | ||
|
|
491d120c25 | ||
|
|
82d071d52c | ||
|
|
8190654a91 | ||
|
|
5e21702d16 | ||
|
|
3df4172237 | ||
|
|
e0710a2ec1 | ||
|
|
d20fc3b9ce | ||
|
|
f25faec108 | ||
|
|
2750658c3c | ||
|
|
e6bc5bbab3 | ||
|
|
de5f3a9d9e | ||
|
|
7f0da012c2 | ||
|
|
76ecbf5eb0 | ||
|
|
06881a4c70 | ||
|
|
c14fdd82db | ||
|
|
b1a02ad930 | ||
|
|
2b580c60e9 | ||
|
|
27ffba3b14 | ||
|
|
fb7bf437ba | ||
|
|
d8baf0b9e7 | ||
|
|
a6945fe880 | ||
|
|
6ff77dd8c7 | ||
|
|
1d925a608b | ||
|
|
646669ca75 | ||
|
|
0ec2baaa83 | ||
|
|
5abaaefd1c | ||
|
|
687afea0f2 | ||
|
|
7398d4fa2b | ||
|
|
d73c8c3627 | ||
|
|
f921b91c5b | ||
|
|
8ed3c1b159 | ||
|
|
4f947cdbb5 | ||
|
|
ec6c29052a | ||
|
|
6b117502f3 | ||
|
|
a367aa0b31 | ||
|
|
7f6843df55 | ||
|
|
4b485d54b6 | ||
|
|
e80a040db4 | ||
|
|
c357fd085b | ||
|
|
d0c5f29b0a | ||
|
|
3e7b20c379 | ||
|
|
f4a997c0ef | ||
|
|
102e74bb63 | ||
|
|
84fa15e44c | ||
|
|
7b8ecf8fe3 | ||
|
|
79ebf920ea | ||
|
|
0d05d42f70 | ||
|
|
95989b6da7 | ||
|
|
22e57f99a1 | ||
|
|
b9d29c0417 | ||
|
|
edc33db9e8 | ||
|
|
45d8b45d96 | ||
|
|
b6a6d7dc68 | ||
|
|
c57b84cd0b | ||
|
|
78e7841b5e | ||
|
|
e41d981449 | ||
|
|
381227af66 | ||
|
|
2f8d0728ba | ||
|
|
13bfc5f2f9 | ||
|
|
f8d3b571ba | ||
|
|
64b1e52d4c | ||
|
|
b650f7ee9a | ||
|
|
d4446280c7 | ||
|
|
3443a233f4 | ||
|
|
861f17bc4d | ||
|
|
41bbe18e3d | ||
|
|
5f9c357a15 | ||
|
|
c089298f46 | ||
|
|
be968e0caa | ||
|
|
d32b7b612a | ||
|
|
160386be62 | ||
|
|
64bdde8f33 | ||
|
|
8739318101 | ||
|
|
1ac569badd | ||
|
|
e38f891693 | ||
|
|
89a277c354 | ||
|
|
8238387c7d | ||
|
|
6c829581af | ||
|
|
d180650c1b | ||
|
|
772bf5518f | ||
|
|
153dc0bdad | ||
|
|
b7ea8e6001 | ||
|
|
b0bf5ba8e0 | ||
|
|
8169a5f776 | ||
|
|
b4b8914448 | ||
|
|
2ea2c89b4a | ||
|
|
479ca59a6a | ||
|
|
343f987a78 | ||
|
|
492465a525 | ||
|
|
f185e44f93 | ||
|
|
5f2c318baf | ||
|
|
531a5b77d0 | ||
|
|
f9245cdfed | ||
|
|
bad9c7a4b9 | ||
|
|
576e0949e0 | ||
|
|
d807648d2b | ||
|
|
47ea88e025 | ||
|
|
345a473ee0 | ||
|
|
c6dc432d06 | ||
|
|
af3bd55535 | ||
|
|
5fa1d86191 | ||
|
|
d4859b675a | ||
|
|
9aa427031e | ||
|
|
9e8fe7bc1e | ||
|
|
90813c959d | ||
|
|
940a65954c | ||
|
|
9b4e753a79 | ||
|
|
e0be9029e4 | ||
|
|
0ae213c240 | ||
|
|
08117e7a54 | ||
|
|
51c3226070 | ||
|
|
5396d19d7d | ||
|
|
11501e6899 | ||
|
|
4eb49b9119 | ||
|
|
4c2da22750 | ||
|
|
753b9cbae2 | ||
|
|
2c73cabe22 | ||
|
|
d6ca091b7b | ||
|
|
aa3faca246 | ||
|
|
f0e849e9c0 | ||
|
|
ddcbd6a790 | ||
|
|
28e3b6285e | ||
|
|
348db90f7b | ||
|
|
0d780b9b80 | ||
|
|
8ab718becd | ||
|
|
1943c357fa | ||
|
|
3be0d8389b | ||
|
|
d7d1c83276 | ||
|
|
8591e2ffd5 | ||
|
|
c2225bf2fd | ||
|
|
0617d7b49f | ||
|
|
42b288ee98 | ||
|
|
ead7203e40 | ||
|
|
bd2b947255 | ||
|
|
f367bd2d0d | ||
|
|
a8ce44b109 | ||
|
|
3b43d3f0bd | ||
|
|
06da486140 | ||
|
|
a24a7d5636 | ||
|
|
bc197fd665 | ||
|
|
e35cc71cf4 | ||
|
|
7aa774b4ef | ||
|
|
9bb2de79fa | ||
|
|
21246144ab | ||
|
|
0e0845914b | ||
|
|
778e827277 | ||
|
|
f36d4fa38d | ||
|
|
5b89c2d504 | ||
|
|
49b213bb2d | ||
|
|
aed75c7d1b | ||
|
|
ac51e4e4d5 | ||
|
|
d8d84ae629 | ||
|
|
3f75f8ed31 | ||
|
|
8aef4dc2ec | ||
|
|
b1e7ec706b | ||
|
|
b5cd780360 | ||
|
|
a8da94ee6d | ||
|
|
f564c732d4 | ||
|
|
8beb1e5753 | ||
|
|
d98d802a33 | ||
|
|
d750f29fad | ||
|
|
f425d32846 | ||
|
|
043d6b492d | ||
|
|
0bcccfa65e | ||
|
|
0716b1b8d8 | ||
|
|
47168e682d | ||
|
|
718d864dc8 | ||
|
|
22a691ec0f | ||
|
|
dfcc826b7e | ||
|
|
d1ee664ef5 | ||
|
|
abcc6518bb | ||
|
|
2ef9ec3358 | ||
|
|
f84cf3bf2d | ||
|
|
bdcc2d2fa4 | ||
|
|
7e8e7f15a4 | ||
|
|
1ca3ad6237 | ||
|
|
f3c878d949 | ||
|
|
ee0c30ebb1 | ||
|
|
c2f0c908d9 | ||
|
|
3edaaa0400 | ||
|
|
21a443baf2 | ||
|
|
f6f47b5500 | ||
|
|
920c724656 | ||
|
|
e50b814e07 | ||
|
|
2294082168 | ||
|
|
2661a28936 | ||
|
|
31a6834b1f | ||
|
|
f66d98bcfe | ||
|
|
ed7466621f | ||
|
|
ead450aeb8 | ||
|
|
d934cefa8b | ||
|
|
41807837b8 | ||
|
|
4419c816f5 | ||
|
|
4993da727a | ||
|
|
86a163e337 | ||
|
|
c2fb602bee | ||
|
|
f565d6f31a | ||
|
|
5af01bb8ff | ||
|
|
714e783e0d | ||
|
|
fb1b0afddb | ||
|
|
e6fdc56d25 | ||
|
|
5b965e63f8 | ||
|
|
af86550e71 | ||
|
|
e57ae069a7 | ||
|
|
39adc646e6 | ||
|
|
0947e77082 | ||
|
|
637b0ded09 | ||
|
|
9b7a84ea45 | ||
|
|
ead696cbad | ||
|
|
da23ad5eb5 | ||
|
|
b63a0dfa01 | ||
|
|
6c00724a84 | ||
|
|
7619224809 | ||
|
|
e0d3dd9050 | ||
|
|
71bf5e0e4f | ||
|
|
540e530420 | ||
|
|
070a8cad15 | ||
|
|
5d094b38b0 | ||
|
|
af3d385ff5 | ||
|
|
c352a0d698 | ||
|
|
060f64b4d2 | ||
|
|
4f032cd946 | ||
|
|
cee6039ec3 | ||
|
|
c8f8b818e0 | ||
|
|
08922ae284 | ||
|
|
51d606a54e | ||
|
|
2b76d71c67 | ||
|
|
67edd19eb8 | ||
|
|
eaaa526cfc | ||
|
|
843c0f6c37 | ||
|
|
44ebf38e47 | ||
|
|
d640e7d2ea | ||
|
|
66736519ed | ||
|
|
73f4c98be9 | ||
|
|
b2ff7fefaa | ||
|
|
2edb95dfdd | ||
|
|
e0bb9960fb | ||
|
|
71608845c0 | ||
|
|
0048e82151 | ||
|
|
6116bc9ca4 | ||
|
|
c7cbbdd1c8 | ||
|
|
b599033c54 | ||
|
|
7bf6a9fadc | ||
|
|
4490360e11 | ||
|
|
a4dfe0d279 | ||
|
|
47679d1d3b | ||
|
|
62e17daf78 | ||
|
|
1f359f2a72 | ||
|
|
cebe7a28c0 | ||
|
|
91778f43b7 | ||
|
|
e3602e83ce | ||
|
|
36742bf30b | ||
|
|
e614800d5f | ||
|
|
151a114235 | ||
|
|
c842be3a52 | ||
|
|
a264fac2b4 | ||
|
|
0767ef4902 | ||
|
|
abe3357e67 | ||
|
|
19457efe9b | ||
|
|
487a2eb3e3 | ||
|
|
38f66147cb | ||
|
|
ffa26c20b5 | ||
|
|
430ada7f84 | ||
|
|
988e37eb42 | ||
|
|
1938b90bce | ||
|
|
f61d1403f3 | ||
|
|
d228f34f64 | ||
|
|
97049556ed | ||
|
|
b64c6a94cc | ||
|
|
edc655bae7 | ||
|
|
884af48270 | ||
|
|
95f018fad3 | ||
|
|
b44758a195 | ||
|
|
f1c20d490e | ||
|
|
04372d498e | ||
|
|
63ea899605 | ||
|
|
4fdd145d1e | ||
|
|
57ee68b0e0 | ||
|
|
99c6884681 | ||
|
|
1c1438e9bc | ||
|
|
22f1b3e489 | ||
|
|
fb1ad5800c | ||
|
|
4dd15b05ef | ||
|
|
d7cdb644c4 | ||
|
|
bfcf4950b3 | ||
|
|
6612c97f05 | ||
|
|
b92cf62726 | ||
|
|
a53ba33a81 | ||
|
|
c04d38cf63 | ||
|
|
a051adcbc0 | ||
|
|
900a9cdf72 | ||
|
|
611b329934 | ||
|
|
2642788bc1 | ||
|
|
8268445f0b | ||
|
|
c12dcd9263 | ||
|
|
3142524809 | ||
|
|
4c9d3bd9d7 | ||
|
|
ba103066b8 | ||
|
|
0b0d6179a2 | ||
|
|
e8806d4ef8 | ||
|
|
c96e5f174c | ||
|
|
315242e069 | ||
|
|
a94fa5fbe3 | ||
|
|
90203afdd0 | ||
|
|
2c0d098852 | ||
|
|
e4290cd465 | ||
|
|
46b85dec5c | ||
|
|
bbc34bd009 | ||
|
|
c495317e65 | ||
|
|
a946050228 | ||
|
|
83789dcefa | ||
|
|
4f1f9bf1ce | ||
|
|
32c72c2f35 | ||
|
|
42ae0035af | ||
|
|
96e28fdbe6 | ||
|
|
0f889f50cf | ||
|
|
478eaebdec | ||
|
|
7ecf229db5 | ||
|
|
dda75c6099 | ||
|
|
28d5ac9f90 | ||
|
|
52acfb4636 | ||
|
|
2a64bad385 | ||
|
|
1a73699f1a | ||
|
|
1b5a7a0063 | ||
|
|
4b3eb440cf | ||
|
|
d1f457866b | ||
|
|
6f29aa82cb | ||
|
|
b1a2ff7d84 | ||
|
|
883b16e251 | ||
|
|
116b39cf6a | ||
|
|
eae1db9c7d | ||
|
|
730bab5d40 | ||
|
|
d52dac233e | ||
|
|
1f37277a8d | ||
|
|
15014d7055 | ||
|
|
7a35ca05b1 | ||
|
|
be6b6909d9 | ||
|
|
42c1d26b2e | ||
|
|
d38f8ad2ec | ||
|
|
023fd58f05 | ||
|
|
ad10a8f07f | ||
|
|
7d6cf039d4 | ||
|
|
f0271e17b0 | ||
|
|
57954816b2 | ||
|
|
3e4e1f9bb9 | ||
|
|
7340918209 | ||
|
|
707df4f941 | ||
|
|
8eb6b83411 | ||
|
|
e0aedd617b | ||
|
|
ee81620c14 | ||
|
|
2d027af71a | ||
|
|
9d5f06b810 | ||
|
|
b8fdcda2fb | ||
|
|
0dac7b7b81 | ||
|
|
b750e753d6 | ||
|
|
ee33e92bed | ||
|
|
7413a643f6 | ||
|
|
34452f9220 | ||
|
|
24831b2462 | ||
|
|
cd4a18cb5a | ||
|
|
c00b3df8b2 | ||
|
|
1beb821b2c | ||
|
|
a58791048a | ||
|
|
f0c6e8612f | ||
|
|
1cebbf92e6 | ||
|
|
a64982583b | ||
|
|
cb8b0915de | ||
|
|
1d2576b06d | ||
|
|
ead9a3c0e9 | ||
|
|
59ba418faa | ||
|
|
c51a332560 | ||
|
|
a73d2feed6 | ||
|
|
6929193e9d | ||
|
|
fea4ad9e89 | ||
|
|
68099049d4 | ||
|
|
44cf08bdfd | ||
|
|
9e32cf17a4 | ||
|
|
0661367ebb | ||
|
|
3c07e42ba2 | ||
|
|
2cd6376818 | ||
|
|
5e7f7750de | ||
|
|
2a223ec6db | ||
|
|
637319ec2c |
15
.gitignore
vendored
@@ -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*/
|
||||
|
||||
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"esbonio.sphinx.confDir": "",
|
||||
"python.formatting.provider": "autopep8"
|
||||
}
|
||||
75
CODE_OF_CONDUCT
Normal 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 project’s 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
@@ -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>`__.
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
include LICENSE.txt
|
||||
include MANIFEST.in
|
||||
include README.rst
|
||||
include setup.py
|
||||
57
README.rst
@@ -1,30 +1,47 @@
|
||||
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
|
||||
A powerful and efficient library to interact with
|
||||
`Facebook's Messenger <https://www.facebook.com/messages/>`__, using just your email and password.
|
||||
|
||||
.. image:: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6-blue.svg
|
||||
:target: https://pypi.python.org/pypi/fbchat
|
||||
:alt: Supported python versions: 2.7, 3.4, 3.5 and 3.6
|
||||
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.
|
||||
|
||||
.. image:: https://readthedocs.org/projects/fbchat/badge/?version=master
|
||||
:target: https://fbchat.readthedocs.io
|
||||
:alt: Documentation
|
||||
``fbchat`` currently support:
|
||||
|
||||
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>`__.
|
||||
- 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).
|
||||
|
||||
**No XMPP or API key is needed**. Just use your email and password.
|
||||
Essentially, everything you need to make an amazing Facebook bot!
|
||||
|
||||
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>`__
|
||||
|
||||
Installation:
|
||||
Version Warning
|
||||
---------------
|
||||
``v2`` is currently being developed at the ``master`` branch and it's highly unstable.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ pip install fbchat
|
||||
Caveats
|
||||
-------
|
||||
|
||||
© Copyright 2015 - 2017 by Taehoon Kim / `@carpedm20 <http://carpedm20.github.io/about/>`__
|
||||
``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!
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. code-block::
|
||||
|
||||
$ pip install git+https://git.karaolidis.com/karaolidis/fbchat.git
|
||||
|
||||
|
||||
Acknowledgements
|
||||
----------------
|
||||
|
||||
This project is a fork of `fbchat <https://github.com/fbchat-dev/fbchat>`__ and was originally inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`__.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = python3.6 -msphinx
|
||||
SPHINXPROJ = fbchat
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
BIN
docs/_static/find-group-id.png
vendored
|
Before Width: | Height: | Size: 54 KiB |
1
docs/_static/license.svg
vendored
@@ -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 |
1
docs/_static/python-versions.svg
vendored
@@ -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 |
26
docs/_templates/layout.html
vendored
@@ -1,26 +0,0 @@
|
||||
{% extends '!layout.html' %}
|
||||
|
||||
{% block extrahead %}
|
||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
||||
<!-- Alabaster (krTheme++) Hacks, modified version of Kenneth Reitz' https://github.com/kennethreitz/requests/blob/master/docs/_templates/hacks.html -->
|
||||
<style type="text/css">
|
||||
/* Rezzy requires precise alignment. */
|
||||
img.logo {margin-left: -20px!important;}
|
||||
/* "Quick Search" should be capitalized. */
|
||||
div#searchbox h3 {text-transform: capitalize;}
|
||||
/* Go button should be behind input field */
|
||||
div.sphinxsidebar div#searchbox input[type="text"] {width: 160px}
|
||||
div#searchbox form div {display: inline-block;}
|
||||
/* Make the document a little wider, less code is cut-off. */
|
||||
div.document {width: 1008px;}
|
||||
/* Much-improved spacing around code blocks. */
|
||||
div.highlight pre {padding: 11px 14px;}
|
||||
/* Remain Responsive! */
|
||||
@media screen and (max-width: 1008px) {
|
||||
div.sphinxsidebar {display: none;}
|
||||
div.document {width: 100%!important;}
|
||||
/* Have code blocks escape the document right-margin. */
|
||||
div.highlight pre {margin-right: -30px;}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
13
docs/_templates/sidebar.html
vendored
@@ -1,13 +0,0 @@
|
||||
<h3>
|
||||
<a href="{{ pathto(master_doc) }}">{{ _(project) }}</a>
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
<a class="github-button" href="https://github.com/carpedm20/fbchat" data-size="large" data-show-count="true" aria-label="Star carpedm20/fbchat on GitHub">Star</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ _(shorttitle) }}
|
||||
</p>
|
||||
|
||||
{{ toctree() }}
|
||||
44
docs/api.rst
@@ -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:
|
||||
191
docs/conf.py
@@ -1,191 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# 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.
|
||||
|
||||
# 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.
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
import fbchat
|
||||
import tests
|
||||
from fbchat import __copyright__, __author__, __version__, __description__
|
||||
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#
|
||||
# needs_sphinx = '1.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'
|
||||
]
|
||||
|
||||
# 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'
|
||||
|
||||
# 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
|
||||
|
||||
# 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']
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = True
|
||||
|
||||
|
||||
# -- 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'
|
||||
|
||||
# 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 = {}
|
||||
|
||||
# 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']
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
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',
|
||||
}
|
||||
|
||||
# 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'),
|
||||
]
|
||||
|
||||
|
||||
# -- 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)
|
||||
]
|
||||
|
||||
|
||||
# -- 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'),
|
||||
]
|
||||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'https://docs.python.org/3/': None}
|
||||
|
||||
|
||||
|
||||
add_function_parentheses = False
|
||||
|
||||
html_theme_options = {
|
||||
'show_powered_by': False,
|
||||
'github_user': 'carpedm20',
|
||||
'github_repo': project,
|
||||
'github_banner': True,
|
||||
'show_related': False
|
||||
}
|
||||
|
||||
html_sidebars = {
|
||||
'**': ['sidebar.html', 'searchbox.html']
|
||||
}
|
||||
|
||||
html_show_sphinx = False
|
||||
html_show_sourcelink = False
|
||||
autoclass_content = 'init'
|
||||
html_short_title = description
|
||||
@@ -1,56 +0,0 @@
|
||||
.. 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
|
||||
|
||||
|
||||
Basic example
|
||||
-------------
|
||||
|
||||
This will show basic usage of `fbchat`
|
||||
|
||||
.. literalinclude:: ../examples/basic_usage.py
|
||||
|
||||
|
||||
Interacting with Threads
|
||||
------------------------
|
||||
|
||||
This will interract with the thread in every way `fbchat` supports
|
||||
|
||||
.. literalinclude:: ../examples/interract.py
|
||||
|
||||
|
||||
Fetching Information
|
||||
--------------------
|
||||
|
||||
This will show the different ways of fetching information about users and threads
|
||||
|
||||
.. literalinclude:: ../examples/fetch.py
|
||||
|
||||
|
||||
Echobot
|
||||
-------
|
||||
|
||||
This will reply to any message with the same message
|
||||
|
||||
.. literalinclude:: ../examples/echobot.py
|
||||
|
||||
|
||||
Remove Bot
|
||||
----------
|
||||
|
||||
This will remove a user from a group if they write the message `Remove me!`
|
||||
|
||||
.. literalinclude:: ../examples/removebot.py
|
||||
|
||||
|
||||
"Prevent changes"-Bot
|
||||
---------------------
|
||||
|
||||
This will prevent chat color, emoji, nicknames and chat name from being changed.
|
||||
It will also prevent people from being added and removed
|
||||
|
||||
.. literalinclude:: ../examples/keepbot.py
|
||||
44
docs/faq.rst
@@ -1,44 +0,0 @@
|
||||
.. highlight:: python
|
||||
.. module:: fbchat
|
||||
.. _faq:
|
||||
|
||||
FAQ
|
||||
===
|
||||
|
||||
Version X broke my installation
|
||||
-------------------------------
|
||||
|
||||
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
|
||||
|
||||
Downgrade to an earlier version of fbchat, run this command
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
$ pip install fbchat==<X>
|
||||
|
||||
Where you replace ``<X>`` with the version you want to use
|
||||
|
||||
|
||||
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
|
||||
@@ -1,66 +0,0 @@
|
||||
.. 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.
|
||||
|
||||
.. 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
|
||||
|
||||
fbchat: Facebook Chat (Messenger) for Python
|
||||
============================================
|
||||
|
||||
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`
|
||||
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
install
|
||||
intro
|
||||
examples
|
||||
testing
|
||||
api
|
||||
todo
|
||||
faq
|
||||
@@ -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
|
||||
200
docs/intro.rst
@@ -1,200 +0,0 @@
|
||||
.. 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
|
||||
|
||||
|
||||
.. _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`)::
|
||||
|
||||
from fbchat import Client
|
||||
from fbchat.models import *
|
||||
client = Client('<email>', '<password>')
|
||||
|
||||
Replace ``<email>`` and ``<password>`` with your email and password respectively
|
||||
|
||||
.. 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
|
||||
|
||||
If you want to change how verbose `fbchat` is, change the logging level (in :class:`Client`)
|
||||
|
||||
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`::
|
||||
|
||||
if not client.isLoggedIn():
|
||||
client.login('<email>', '<password>')
|
||||
|
||||
When you're done using the client, and want to securely logout, use :func:`Client.logout`::
|
||||
|
||||
client.logout()
|
||||
|
||||
|
||||
.. _intro_threads:
|
||||
|
||||
Threads
|
||||
-------
|
||||
|
||||
A thread can refer to two things: A Messenger group chat or a single Facebook user
|
||||
|
||||
: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
|
||||
|
||||
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`
|
||||
|
||||
You can get your own user ID by using :any:`Client.uid`
|
||||
|
||||
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:
|
||||
|
||||
.. 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
|
||||
|
||||
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::
|
||||
|
||||
client.sendMessage('<message>', thread_id='<user id>', thread_type=ThreadType.USER)
|
||||
client.sendMessage('<message>', thread_id='<group id>', thread_type=ThreadType.GROUP)
|
||||
|
||||
Some functions (e.g. :func:`Client.changeThreadColor`) don't require a thread type, so in these cases you just provide the thread ID::
|
||||
|
||||
client.changeThreadColor(ThreadColor.BILOBA_FLOWER, thread_id='<user id>')
|
||||
client.changeThreadColor(ThreadColor.MESSENGER_BLUE, thread_id='<group 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
|
||||
|
||||
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::
|
||||
|
||||
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))
|
||||
|
||||
Since this uses Facebook's search functions, you don't have to specify the whole name, first names will usually be enough
|
||||
|
||||
You can see a full example showing all the possible ways to fetch information with `fbchat` by going to :ref:`examples`
|
||||
|
||||
|
||||
.. _intro_sessions:
|
||||
|
||||
Sessions
|
||||
--------
|
||||
|
||||
`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::
|
||||
|
||||
session_cookies = client.getSession()
|
||||
|
||||
Then you can use :func:`Client.setSession`::
|
||||
|
||||
client.setSession(session_cookies)
|
||||
|
||||
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)::
|
||||
|
||||
client = Client('<email>', '<password>', session_cookies=session_cookies)
|
||||
|
||||
.. warning::
|
||||
You session cookies can be just as valueable as you password, so store them with equal care
|
||||
|
||||
|
||||
.. _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
|
||||
|
||||
.. note::
|
||||
You can identify the event methods by their `on` prefix, e.g. `onMessage`
|
||||
|
||||
The event actions can be changed by subclassing the :class:`Client`, and then overwriting the event methods::
|
||||
|
||||
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
|
||||
|
||||
client = CustomClient('<email>', '<password>')
|
||||
|
||||
**Notice:** The following snippet is as equally valid as the previous one::
|
||||
|
||||
class CustomClient(Client):
|
||||
def onMessage(self, message, author_id, thread_id, thread_type, **kwargs):
|
||||
# Do something with the message here
|
||||
pass
|
||||
|
||||
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
|
||||
@@ -1,36 +0,0 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=python -msphinx
|
||||
)
|
||||
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.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
||||
|
||||
:end
|
||||
popd
|
||||
@@ -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
|
||||
@@ -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::
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=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 = client.fetch_image_url(image.id)
|
||||
print(url)
|
||||
|
||||
@@ -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(thread=thread, id="<message id>")
|
||||
|
||||
# Will react to a message with a 😍 emoji
|
||||
client.reactToMessage('<message id>', MessageReaction.LOVE)
|
||||
message.react("😍")
|
||||
|
||||
@@ -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([event.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)
|
||||
|
||||
@@ -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 event.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)
|
||||
|
||||
42
examples/session_handling.py
Normal 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(cookies, f)
|
||||
|
||||
|
||||
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
|
||||
@@ -1,29 +1,129 @@
|
||||
# -*- 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.13'
|
||||
__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,
|
||||
MessageSnippet,
|
||||
MessageData,
|
||||
)
|
||||
|
||||
# Events
|
||||
from ._events import (
|
||||
# _common
|
||||
Event,
|
||||
UnknownEvent,
|
||||
ThreadEvent,
|
||||
Connect,
|
||||
Disconnect,
|
||||
# _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.0a5"
|
||||
|
||||
__all__ = ("Session", "Listener", "Client")
|
||||
|
||||
|
||||
from . import _fix_module_metadata
|
||||
|
||||
_fix_module_metadata.fixup_module_metadata(globals())
|
||||
del _fix_module_metadata
|
||||
|
||||
650
fbchat/_client.py
Normal file
@@ -0,0 +1,650 @@
|
||||
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 arbitrary.
|
||||
|
||||
Example:
|
||||
Get the name of an arbitrary 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 not fixable, 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 = _util.now()
|
||||
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.messenger.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 thread 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
@@ -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)
|
||||
132
fbchat/_events/__init__.py
Normal file
@@ -0,0 +1,132 @@
|
||||
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_thread_typing(cls, session, data):
|
||||
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")
|
||||
|
||||
|
||||
@attrs_event
|
||||
class Connect(Event):
|
||||
"""The client was connected to Facebook.
|
||||
|
||||
This is not guaranteed to be triggered the same amount of times `Disconnect`!
|
||||
"""
|
||||
|
||||
|
||||
@attrs_event
|
||||
class Disconnect(Event):
|
||||
"""The client lost the connection to Facebook.
|
||||
|
||||
This is not guaranteed to be triggered the same amount of times `Connect`!
|
||||
"""
|
||||
|
||||
#: The reason / error string for the disconnect
|
||||
reason = attr.ib(type=str)
|
||||
|
||||
|
||||
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_thread_typing(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
|
||||
136
fbchat/_events/_client_payload.py
Normal 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
@@ -0,0 +1,62 @@
|
||||
import attr
|
||||
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:
|
||||
"""Base class for all events."""
|
||||
|
||||
@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
|
||||
|
||||
@classmethod
|
||||
def _parse_fetch(cls, session, data):
|
||||
author = _threads.User(session=session, id=data["message_sender"]["id"])
|
||||
at = _util.millis_to_datetime(int(data["timestamp_precise"]))
|
||||
return author, at
|
||||
214
fbchat/_events/_delta_class.py
Normal 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. If ``None``, the title was removed
|
||||
title = attr.ib(type=Optional[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"] or None, 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)
|
||||
331
fbchat/_events/_delta_type.py
Normal 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
@@ -0,0 +1,165 @@
|
||||
import attr
|
||||
import requests
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# 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=Optional[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=Optional[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=Optional[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`
|
||||
raise GraphQLError(
|
||||
# TODO: What data is always available?
|
||||
message=error.get("summary", "Unknown error"),
|
||||
description=error.get("message") or error.get("description") or "",
|
||||
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
|
||||
45
fbchat/_fix_module_metadata.py
Normal 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
@@ -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
|
||||
)
|
||||
407
fbchat/_listen.py
Normal file
@@ -0,0 +1,407 @@
|
||||
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, List
|
||||
|
||||
|
||||
HOST = "edge-chat.messenger.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=List[_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"
|
||||
)
|
||||
# TODO: Find a way to tell the user that they may now be missing events
|
||||
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.messenger.com/chat"
|
||||
),
|
||||
"User-Agent": self.session._session.headers["User-Agent"],
|
||||
"Origin": "https://www.messenger.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) -> bool:
|
||||
# Try reconnecting
|
||||
self._configure_connect_options()
|
||||
try:
|
||||
self._mqtt.reconnect()
|
||||
return True
|
||||
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()
|
||||
return False
|
||||
|
||||
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 not self._reconnect():
|
||||
pass
|
||||
|
||||
yield _events.Connect()
|
||||
|
||||
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:
|
||||
yield _events.Disconnect(reason="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
|
||||
yield _events.Disconnect(reason="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)
|
||||
reason = "MQTT Error: {}, retrying".format(err)
|
||||
yield _events.Disconnect(reason=reason)
|
||||
|
||||
while not self._reconnect():
|
||||
pass
|
||||
|
||||
yield _events.Connect()
|
||||
|
||||
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 receiving 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)
|
||||
9
fbchat/_models/__init__.py
Normal 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 *
|
||||
81
fbchat/_models/_attachment.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import attr
|
||||
from . import Image
|
||||
from .._common import attrs_default
|
||||
from .. import _util
|
||||
|
||||
from typing import Optional, Sequence
|
||||
|
||||
|
||||
@attrs_default
|
||||
class Attachment:
|
||||
"""Represents a Facebook attachment."""
|
||||
|
||||
#: The attachment ID
|
||||
id = attr.ib(None, type=Optional[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=Optional[str])
|
||||
#: Target URL
|
||||
url = attr.ib(None, type=Optional[str])
|
||||
#: Original URL if Facebook redirects the URL
|
||||
original_url = attr.ib(None, type=Optional[str])
|
||||
#: Title of the attachment
|
||||
title = attr.ib(None, type=Optional[str])
|
||||
#: Description of the attachment
|
||||
description = attr.ib(None, type=Optional[str])
|
||||
#: Name of the source
|
||||
source = attr.ib(None, type=Optional[str])
|
||||
#: The attached image
|
||||
image = attr.ib(None, type=Optional[Image])
|
||||
#: URL of the original image if Facebook uses ``safe_image``
|
||||
original_image_url = attr.ib(None, type=Optional[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")
|
||||
],
|
||||
)
|
||||
81
fbchat/_models/_common.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import attr
|
||||
import datetime
|
||||
import enum
|
||||
from .._common import attrs_default
|
||||
from .. import _util
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
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(type=bool)
|
||||
#: When the user was last active
|
||||
last_active = attr.ib(None, type=Optional[datetime.datetime])
|
||||
#: Whether the user is playing Messenger game now
|
||||
in_game = attr.ib(None, type=Optional[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=Optional[int])
|
||||
#: Height of the image
|
||||
height = attr.ib(None, type=Optional[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
@@ -0,0 +1,195 @@
|
||||
import attr
|
||||
import datetime
|
||||
from . import Image, Attachment
|
||||
from .._common import attrs_default
|
||||
from .. import _util
|
||||
|
||||
from typing import Set, Optional
|
||||
|
||||
|
||||
@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=Optional[str])
|
||||
#: Size of the file in bytes
|
||||
size = attr.ib(None, type=Optional[int])
|
||||
#: Name of the file
|
||||
name = attr.ib(None, type=Optional[str])
|
||||
#: Whether Facebook determines that this file may be harmful
|
||||
is_malicious = attr.ib(None, type=Optional[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=Optional[str])
|
||||
#: URL of the audio file
|
||||
url = attr.ib(None, type=Optional[str])
|
||||
#: Duration of the audio clip
|
||||
duration = attr.ib(None, type=Optional[datetime.timedelta])
|
||||
#: Audio type
|
||||
audio_type = attr.ib(None, type=Optional[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=Optional[str])
|
||||
#: Width of original image
|
||||
width = attr.ib(None, converter=_util.int_or_none, type=Optional[int])
|
||||
#: Height of original image
|
||||
height = attr.ib(None, converter=_util.int_or_none, type=Optional[int])
|
||||
#: Whether the image is animated
|
||||
is_animated = attr.ib(None, type=Optional[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=Optional[int])
|
||||
#: Width of original video
|
||||
width = attr.ib(None, type=Optional[int])
|
||||
#: Height of original video
|
||||
height = attr.ib(None, type=Optional[int])
|
||||
#: Length of video
|
||||
duration = attr.ib(None, type=Optional[datetime.timedelta])
|
||||
#: URL to very compressed preview video
|
||||
preview_url = attr.ib(None, type=Optional[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
|
||||
100
fbchat/_models/_location.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import attr
|
||||
import datetime
|
||||
from . import Image, Attachment
|
||||
from .._common import attrs_default
|
||||
from .. import _util, _exception
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@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=Optional[float])
|
||||
#: Longitude of the location
|
||||
longitude = attr.ib(None, type=Optional[float])
|
||||
#: Image showing the map of the location
|
||||
image = attr.ib(None, type=Optional[Image])
|
||||
#: URL to Bing maps with the location
|
||||
url = attr.ib(None, type=Optional[str])
|
||||
# Address of the location
|
||||
address = attr.ib(None, type=Optional[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, type=Optional[str])
|
||||
#: When live location expires
|
||||
expires_at = attr.ib(None, type=Optional[datetime.datetime])
|
||||
#: True if live location is expired
|
||||
is_expired = attr.ib(None, type=Optional[bool])
|
||||
|
||||
@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"),
|
||||
)
|
||||
480
fbchat/_models/_message.py
Normal file
@@ -0,0 +1,480 @@
|
||||
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
|
||||
from typing import Optional, Mapping, Sequence, Any
|
||||
|
||||
|
||||
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()
|
||||
#: 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.
|
||||
|
||||
Args:
|
||||
reaction: Reaction emoji to use, or if ``None``, removes reaction.
|
||||
|
||||
Example:
|
||||
>>> message.react("😍")
|
||||
"""
|
||||
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)
|
||||
#: 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)
|
||||
#: When the message was sent
|
||||
created_at = attr.ib(type=datetime.datetime)
|
||||
#: The actual message
|
||||
text = attr.ib(None, type=Optional[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=Optional[EmojiSize])
|
||||
#: Whether the message is read
|
||||
is_read = attr.ib(None, type=Optional[bool])
|
||||
#: People IDs who read the message, only works with `ThreadABC.fetch_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=Optional[_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=Optional[bool])
|
||||
#: Message ID you want to reply to
|
||||
reply_to_id = attr.ib(None, type=Optional[str])
|
||||
#: Replied message
|
||||
replied_to = attr.ib(None, type=Optional[Any])
|
||||
#: Whether the message was forwarded
|
||||
forwarded = attr.ib(False, type=Optional[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
@@ -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, Optional
|
||||
|
||||
|
||||
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=Optional[str])
|
||||
#: Plan location ID
|
||||
location_id = attr.ib(None, converter=lambda x: x or "", type=Optional[str])
|
||||
#: ID of the plan creator
|
||||
author_id = attr.ib(None, type=Optional[str])
|
||||
#: `User` ids mapped to their `GuestStatus`
|
||||
guests = attr.ib(None, type=Optional[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
@@ -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"),
|
||||
)
|
||||
82
fbchat/_models/_quick_reply.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import attr
|
||||
from . import Attachment
|
||||
from .._common import attrs_default
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@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=Optional[str])
|
||||
#: URL of the quick reply image
|
||||
image_url = attr.ib(None, type=Optional[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
|
||||
image_url = attr.ib(None, type=Optional[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
|
||||
image_url = attr.ib(None, type=Optional[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
|
||||
57
fbchat/_models/_sticker.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import attr
|
||||
from . import Image, Attachment
|
||||
from .._common import attrs_default
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@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=Optional[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=Optional[str])
|
||||
#: URL to a large spritemap
|
||||
large_sprite_image = attr.ib(None, type=Optional[str])
|
||||
#: The amount of frames present in the spritemap pr. row
|
||||
frames_per_row = attr.ib(None, type=Optional[int])
|
||||
#: The amount of frames present in the spritemap pr. column
|
||||
frames_per_col = attr.ib(None, type=Optional[int])
|
||||
#: The total amount of frames in the spritemap
|
||||
frame_count = attr.ib(None, type=Optional[int])
|
||||
#: The frame rate the spritemap is intended to be played in
|
||||
frame_rate = attr.ib(None, type=Optional[int])
|
||||
|
||||
#: The sticker's image
|
||||
image = attr.ib(None, type=Optional[Image])
|
||||
#: The sticker's label/name
|
||||
label = attr.ib(None, type=Optional[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,
|
||||
)
|
||||
584
fbchat/_session.py
Normal file
@@ -0,0 +1,584 @@
|
||||
import attr
|
||||
import datetime
|
||||
import requests
|
||||
import random
|
||||
import re
|
||||
import json
|
||||
|
||||
# TODO: Only import when required
|
||||
# Or maybe just replace usage with `html.parser`?
|
||||
import bs4
|
||||
|
||||
from ._common import log, kw_only
|
||||
from . import _graphql, _util, _exception
|
||||
|
||||
from typing import Optional, Mapping, Callable, Any
|
||||
|
||||
|
||||
SERVER_JS_DEFINE_REGEX = re.compile(
|
||||
r'(?:"ServerJS".{,100}\.handle\({.*"define":)'
|
||||
r'|(?:ServerJS.{,100}\.handleWithCustomApplyEach\(ScheduledApplyEach,{.*"define":)'
|
||||
r'|(?:require\("ServerJSDefine"\)\)?\.handleDefines\()'
|
||||
r'|(?:"require":\[\["ScheduledServerJS".{,100}"define":)'
|
||||
)
|
||||
SERVER_JS_DEFINE_JSON_DECODER = json.JSONDecoder()
|
||||
|
||||
|
||||
def parse_server_js_define(html: str) -> Mapping[str, Any]:
|
||||
"""Parse ``ServerJSDefine`` entries from a HTML document."""
|
||||
# Find points where we should start parsing
|
||||
define_splits = SERVER_JS_DEFINE_REGEX.split(html)
|
||||
|
||||
# TODO: Extract jsmods "require" and "define" from `bigPipe.onPageletArrive`?
|
||||
|
||||
# Skip leading entry
|
||||
_, *define_splits = define_splits
|
||||
|
||||
rtn = []
|
||||
if not define_splits:
|
||||
raise _exception.ParseError("Could not find any ServerJSDefine", data=html)
|
||||
if len(define_splits) < 2:
|
||||
raise _exception.ParseError("Could not find enough ServerJSDefine", data=html)
|
||||
# Parse entries (should be two)
|
||||
for entry in define_splits:
|
||||
try:
|
||||
parsed, _ = SERVER_JS_DEFINE_JSON_DECODER.raw_decode(entry, idx=0)
|
||||
except json.JSONDecodeError as e:
|
||||
raise _exception.ParseError("Invalid ServerJSDefine", data=entry) from e
|
||||
if not isinstance(parsed, list):
|
||||
raise _exception.ParseError("Invalid ServerJSDefine", data=parsed)
|
||||
rtn.extend(parsed)
|
||||
|
||||
# Convert to a dict
|
||||
return _util.get_jsmods_define(rtn)
|
||||
|
||||
|
||||
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.messenger.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 session_factory() -> requests.Session:
|
||||
from . import __version__
|
||||
|
||||
session = requests.session()
|
||||
# Override Facebook's locale detection during the login process.
|
||||
# The locale is only used when giving errors back to the user, so giving the errors
|
||||
# back in English makes it easier for users to report.
|
||||
session.cookies = session.cookies = requests.cookies.merge_cookies(
|
||||
session.cookies, {"locale": "en_US"}
|
||||
)
|
||||
session.headers["Referer"] = "https://www.messenger.com/"
|
||||
# We won't try to set a fake user agent to mask our presence!
|
||||
# Facebook allows us access anyhow, and it makes our motives clearer:
|
||||
# We're not trying to cheat Facebook, we simply want to access their service
|
||||
session.headers["User-Agent"] = "fbchat/{}".format(__version__)
|
||||
return session
|
||||
|
||||
|
||||
def login_cookies(at: datetime.datetime):
|
||||
return {"act": "{}/0".format(_util.datetime_to_millis(at))}
|
||||
|
||||
|
||||
def client_id_factory() -> str:
|
||||
return hex(int(random.random() * 2**31))[2:]
|
||||
|
||||
|
||||
def find_form_request(html: str):
|
||||
soup = bs4.BeautifulSoup(html, "html.parser", parse_only=bs4.SoupStrainer("form"))
|
||||
|
||||
form = soup.form
|
||||
if not form:
|
||||
raise _exception.ParseError("Could not find form to submit", data=html)
|
||||
|
||||
url = form.get("action")
|
||||
if not url:
|
||||
raise _exception.ParseError("Could not find url to submit to", data=form)
|
||||
|
||||
# From what I've seen, it'll always do this!
|
||||
if url.startswith("/"):
|
||||
url = "https://www.facebook.com" + url
|
||||
|
||||
# It's okay to set missing values to something crap, the values are localized, and
|
||||
# hence are not available in the raw HTML
|
||||
data = {
|
||||
x["name"]: x.get("value", "[missing]")
|
||||
for x in form.find_all(["input", "button"])
|
||||
}
|
||||
return url, data
|
||||
|
||||
|
||||
def two_factor_helper(session: requests.Session, r, on_2fa_callback):
|
||||
url, data = find_form_request(r.content.decode("utf-8"))
|
||||
|
||||
# You don't have to type a code if your device is already saved
|
||||
# Repeats if you get the code wrong
|
||||
while "approvals_code" in data:
|
||||
data["approvals_code"] = on_2fa_callback()
|
||||
log.info("Submitting 2FA code")
|
||||
r = session.post(
|
||||
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||
)
|
||||
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||
url, data = find_form_request(r.content.decode("utf-8"))
|
||||
|
||||
# TODO: Can be missing if checkup flow was done on another device in the meantime?
|
||||
if "name_action_selected" in data:
|
||||
data["name_action_selected"] = "save_device"
|
||||
log.info("Saving browser")
|
||||
r = session.post(
|
||||
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||
)
|
||||
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||
url = r.headers.get("Location")
|
||||
if url and url.startswith("https://www.messenger.com/login/auth_token/"):
|
||||
return url
|
||||
url, data = find_form_request(r.content.decode("utf-8"))
|
||||
|
||||
log.info("Starting Facebook checkup flow")
|
||||
r = session.post(
|
||||
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||
)
|
||||
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||
|
||||
url, data = find_form_request(r.content.decode("utf-8"))
|
||||
if "verification_method" in data:
|
||||
raise _exception.NotLoggedIn(
|
||||
"Your account is locked, and you need to log in using a browser, and verify it there!"
|
||||
)
|
||||
if "submit[This was me]" not in data or "submit[This wasn't me]" not in data:
|
||||
raise _exception.ParseError("Could not fill out form properly (2)", data=data)
|
||||
data["submit[This was me]"] = "[any value]"
|
||||
del data["submit[This wasn't me]"]
|
||||
log.info("Verifying login attempt")
|
||||
r = session.post(
|
||||
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||
)
|
||||
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||
|
||||
url, data = find_form_request(r.content.decode("utf-8"))
|
||||
if "name_action_selected" not in data:
|
||||
raise _exception.ParseError("Could not fill out form properly (3)", data=data)
|
||||
data["name_action_selected"] = "save_device"
|
||||
log.info("Saving device again")
|
||||
r = session.post(
|
||||
url, data=data, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||
)
|
||||
log.debug("2FA location: %s", r.headers.get("Location"))
|
||||
return r.headers.get("Location")
|
||||
|
||||
|
||||
def get_error_data(html: str) -> Optional[str]:
|
||||
"""Get error message from a request."""
|
||||
soup = bs4.BeautifulSoup(
|
||||
html, "html.parser", parse_only=bs4.SoupStrainer("form", id="login_form")
|
||||
)
|
||||
# Attempt to extract and format the error string
|
||||
return " ".join(list(soup.stripped_strings)[1:3]) or None
|
||||
|
||||
|
||||
def get_fb_dtsg(define) -> Optional[str]:
|
||||
if "DTSGInitData" in define:
|
||||
return define["DTSGInitData"]["token"]
|
||||
elif "DTSGInitialData" in define:
|
||||
return define["DTSGInitialData"]["token"]
|
||||
return 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)
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
# TODO: Add ability to load previous cookies in here, to avoid 2fa flow
|
||||
@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 two factor
|
||||
authentication code is needed. This should return the requested code.
|
||||
|
||||
Tested using SMS and authentication applications. If you have both
|
||||
enabled, you might not receive an SMS code, and you'll have to use the
|
||||
authentication application.
|
||||
|
||||
Note: Facebook limits the amount of codes they will give you, so if you
|
||||
don't receive a code, be patient, and try again later!
|
||||
|
||||
Example:
|
||||
>>> import fbchat
|
||||
>>> import getpass
|
||||
>>> session = fbchat.Session.login(
|
||||
... input("Email: "),
|
||||
... getpass.getpass(),
|
||||
... on_2fa_callback=lambda: input("2FA Code: ")
|
||||
... )
|
||||
Email: abc@gmail.com
|
||||
Password: ****
|
||||
2FA Code: 123456
|
||||
>>> session.user.id
|
||||
"1234"
|
||||
"""
|
||||
session = session_factory()
|
||||
|
||||
data = {
|
||||
# "jazoest": "2754",
|
||||
# "lsd": "AVqqqRUa",
|
||||
"initial_request_id": "x", # any, just has to be present
|
||||
# "timezone": "-120",
|
||||
# "lgndim": "eyJ3IjoxNDQwLCJoIjo5MDAsImF3IjoxNDQwLCJhaCI6ODc3LCJjIjoyNH0=",
|
||||
# "lgnrnd": "044039_RGm9",
|
||||
"lgnjs": "n",
|
||||
"email": email,
|
||||
"pass": password,
|
||||
"login": "1",
|
||||
"persistent": "1", # Changes the cookie type to have a long "expires"
|
||||
"default_persistent": "0",
|
||||
}
|
||||
|
||||
try:
|
||||
# Should hit a redirect to https://www.messenger.com/
|
||||
# If this does happen, the session is logged in!
|
||||
r = session.post(
|
||||
"https://www.messenger.com/login/password/",
|
||||
data=data,
|
||||
allow_redirects=False,
|
||||
cookies=login_cookies(_util.now()),
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.106 Safari/537.36",
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
|
||||
"accept-language": "en-HU,en;q=0.9,hu-HU;q=0.8,hu;q=0.7,en-US;q=0.6",
|
||||
"cache-control": "max-age=0",
|
||||
"origin": "https://www.messenger.com",
|
||||
"referer": "https://www.messenger.com/login/",
|
||||
"sec-ch-ua": '" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"sec-fetch-user": "?1",
|
||||
"upgrade-insecure-requests": "1",
|
||||
},
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
_exception.handle_requests_error(e)
|
||||
_exception.handle_http_error(r.status_code)
|
||||
|
||||
url = r.headers.get("Location")
|
||||
|
||||
# We weren't redirected, hence the email or password was wrong
|
||||
if not url:
|
||||
error = get_error_data(r.content.decode("utf-8"))
|
||||
raise _exception.NotLoggedIn(error)
|
||||
|
||||
if "checkpoint" in url:
|
||||
if not on_2fa_callback:
|
||||
raise _exception.NotLoggedIn(
|
||||
"2FA code required! Please supply `on_2fa_callback` to .login"
|
||||
)
|
||||
# Get a facebook.com/checkpoint/start url that handles the 2FA flow
|
||||
# This probably works differently for Messenger-only accounts
|
||||
url = _util.get_url_parameter(url, "next")
|
||||
if not url.startswith("https://www.facebook.com/checkpoint/start/"):
|
||||
raise _exception.ParseError("Failed 2fa flow (1)", data=url)
|
||||
|
||||
r = session.get(
|
||||
url, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||
)
|
||||
url = r.headers.get("Location")
|
||||
if not url or not url.startswith("https://www.facebook.com/checkpoint/"):
|
||||
raise _exception.ParseError("Failed 2fa flow (2)", data=url)
|
||||
|
||||
r = session.get(
|
||||
url, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||
)
|
||||
url = two_factor_helper(session, r, on_2fa_callback)
|
||||
|
||||
if not url.startswith("https://www.messenger.com/login/auth_token/"):
|
||||
raise _exception.ParseError("Failed 2fa flow (3)", data=url)
|
||||
|
||||
r = session.get(
|
||||
url, allow_redirects=False, cookies=login_cookies(_util.now())
|
||||
)
|
||||
url = r.headers.get("Location")
|
||||
|
||||
if url != "https://www.messenger.com/":
|
||||
error = get_error_data(r.content.decode("utf-8"))
|
||||
raise _exception.NotLoggedIn("Failed logging in: {}, {}".format(url, error))
|
||||
|
||||
try:
|
||||
return cls._from_session(session=session)
|
||||
except _exception.NotLoggedIn as e:
|
||||
raise _exception.ParseError("Failed loading session", data=r) from e
|
||||
|
||||
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
|
||||
try:
|
||||
r = self._session.get(prefix_url("/login/"), allow_redirects=False)
|
||||
except requests.RequestException as e:
|
||||
_exception.handle_requests_error(e)
|
||||
_exception.handle_http_error(r.status_code)
|
||||
return "https://www.messenger.com/" == r.headers.get("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()
|
||||
"""
|
||||
data = {"fb_dtsg": self._fb_dtsg}
|
||||
try:
|
||||
r = self._session.post(
|
||||
prefix_url("/logout/"), data=data, allow_redirects=False
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
_exception.handle_requests_error(e)
|
||||
_exception.handle_http_error(r.status_code)
|
||||
|
||||
if "Location" not in r.headers:
|
||||
raise _exception.FacebookError("Failed logging out, was not redirected!")
|
||||
if "https://www.messenger.com/login/" != r.headers["Location"]:
|
||||
raise _exception.FacebookError(
|
||||
"Failed logging out, got bad redirect: {}".format(r.headers["Location"])
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_session(cls, session):
|
||||
# TODO: Automatically set user_id when the cookie changes in the session
|
||||
user_id = get_user_id(session)
|
||||
|
||||
# Make a request to the main page to retrieve ServerJSDefine entries
|
||||
try:
|
||||
r = session.get(prefix_url("/"), allow_redirects=True)
|
||||
except requests.RequestException as e:
|
||||
_exception.handle_requests_error(e)
|
||||
_exception.handle_http_error(r.status_code)
|
||||
|
||||
define = parse_server_js_define(r.content.decode("utf-8"))
|
||||
|
||||
fb_dtsg = get_fb_dtsg(define)
|
||||
if fb_dtsg is None:
|
||||
raise _exception.ParseError("Could not find fb_dtsg", data=define)
|
||||
if not fb_dtsg:
|
||||
# Happens when the client is not actually logged in
|
||||
raise _exception.NotLoggedIn(
|
||||
"Found empty fb_dtsg, the session was probably invalid."
|
||||
)
|
||||
|
||||
try:
|
||||
revision = int(define["SiteData"]["client_revision"])
|
||||
except TypeError:
|
||||
raise _exception.ParseError("Could not find client revision", data=define)
|
||||
|
||||
return cls(user_id=user_id, fb_dtsg=fb_dtsg, revision=revision, session=session)
|
||||
|
||||
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"])
|
||||
fb_dtsg = get_fb_dtsg(define)
|
||||
if fb_dtsg:
|
||||
self._fb_dtsg = fb_dtsg
|
||||
|
||||
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 = _util.now()
|
||||
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
|
||||
|
||||
def _uri_share_data(self, data):
|
||||
data["image_height"] = 960
|
||||
data["image_width"] = 960
|
||||
data["__user"] = self.user.id
|
||||
j = self._post("/message_share_attachment/fromURI/", data)
|
||||
return j["payload"]["share_data"]
|
||||
|
||||
def to_file(self, filename):
|
||||
"""Save the session to a file.
|
||||
|
||||
Args:
|
||||
filename: The file to save the session to
|
||||
|
||||
Example:
|
||||
>>> session = fbchat.Session.from_cookies(cookies)
|
||||
>>> session.to_file("session.json")
|
||||
"""
|
||||
with open(filename, "w") as f:
|
||||
json.dump(self.get_cookies(), f)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename):
|
||||
"""Load a session from a file.
|
||||
|
||||
Args:
|
||||
filename: The file to load the session from
|
||||
|
||||
Example:
|
||||
>>> session = fbchat.Session.from_file("session.json")
|
||||
"""
|
||||
with open(filename, "r") as f:
|
||||
cookies = json.load(f)
|
||||
return cls.from_cookies(cookies)
|
||||
4
fbchat/_threads/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from ._abc import *
|
||||
from ._group import *
|
||||
from ._user import *
|
||||
from ._page import *
|
||||
873
fbchat/_threads/_abc.py
Normal file
@@ -0,0 +1,873 @@
|
||||
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,
|
||||
uri: 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
|
||||
uri: Uri to formulate a sharable attachment with
|
||||
|
||||
Example:
|
||||
Send a message with a mention to a thread.
|
||||
|
||||
>>> mention = fbchat.Mention(thread_id="1234", offset=5, length=2)
|
||||
>>> message_id = thread.send_text("A message", mentions=[mention])
|
||||
|
||||
Reply to the message.
|
||||
|
||||
>>> thread.send_text("A reply", reply_to_id=message_id)
|
||||
|
||||
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
|
||||
|
||||
if uri:
|
||||
data.update(self._generate_shareable_attachment(uri))
|
||||
|
||||
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_pinned_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)
|
||||
|
||||
def send_uri(self, uri: str, **kwargs):
|
||||
"""Send a uri preview to a thread.
|
||||
Args:
|
||||
uri: uri to preview
|
||||
"""
|
||||
if kwargs.get('text') is None:
|
||||
kwargs['text'] = None
|
||||
self.send_text(uri=uri, **kwargs)
|
||||
|
||||
def _generate_shareable_attachment(self, uri):
|
||||
"""Send a uri preview to a thread.
|
||||
Args:
|
||||
uri: uri to preview
|
||||
Returns:
|
||||
:ref:`Message ID <intro_message_ids>` of the sent message
|
||||
Raises:
|
||||
FBchatException: If request failed
|
||||
"""
|
||||
url_data = self.session._uri_share_data({"uri": uri})
|
||||
data = self._to_send_data()
|
||||
data["action_type"] = "ma-type:user-generated-message"
|
||||
data["shareable_attachment[share_type]"] = url_data["share_type"]
|
||||
|
||||
# Most uri params will come back as dict
|
||||
if isinstance(url_data["share_params"], dict):
|
||||
data["has_attachment"] = True
|
||||
for key in url_data["share_params"]:
|
||||
if isinstance(url_data["share_params"][key], dict):
|
||||
for key2 in url_data["share_params"][key]:
|
||||
data[
|
||||
"shareable_attachment[share_params][{}][{}]".format(
|
||||
key, key2
|
||||
)
|
||||
] = url_data["share_params"][key][key2]
|
||||
else:
|
||||
data[
|
||||
"shareable_attachment[share_params][{}]".format(key)
|
||||
] = url_data["share_params"][key]
|
||||
|
||||
# Some (such as facebook profile pages) will just be a list
|
||||
else:
|
||||
data["has_attachment"] = False
|
||||
for index, val in enumerate(url_data["share_params"]):
|
||||
data["shareable_attachment[share_params][{}]".format(index)] = val
|
||||
return data
|
||||
|
||||
# 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 not fixable, 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)
|
||||
279
fbchat/_threads/_group.py
Normal file
@@ -0,0 +1,279 @@
|
||||
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, Optional
|
||||
|
||||
|
||||
@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=Optional[_models.Image])
|
||||
#: The name of the group
|
||||
name = attr.ib(None, type=Optional[str])
|
||||
#: When the group was last active / when the last message was sent
|
||||
last_active = attr.ib(None, type=Optional[datetime.datetime])
|
||||
#: Number of messages in the group
|
||||
message_count = attr.ib(None, type=Optional[int])
|
||||
#: Set `Plan`
|
||||
plan = attr.ib(None, type=Optional[_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=Optional[str])
|
||||
#: The groups's default emoji
|
||||
emoji = attr.ib(None, type=Optional[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=Optional[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=Optional[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)
|
||||
}
|
||||
82
fbchat/_threads/_page.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import attr
|
||||
import datetime
|
||||
from ._abc import ThreadABC
|
||||
from .._common import attrs_default
|
||||
from .. import _session, _models
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@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=Optional[datetime.datetime])
|
||||
#: Number of messages in the thread
|
||||
message_count = attr.ib(None, type=Optional[int])
|
||||
#: Set `Plan`
|
||||
plan = attr.ib(None, type=Optional[_models.PlanData])
|
||||
#: The page's custom URL
|
||||
url = attr.ib(None, type=Optional[str])
|
||||
#: The name of the page's location city
|
||||
city = attr.ib(None, type=Optional[str])
|
||||
#: Amount of likes the page has
|
||||
likes = attr.ib(None, type=Optional[int])
|
||||
#: Some extra information about the page
|
||||
sub_title = attr.ib(None, type=Optional[str])
|
||||
#: The page's category
|
||||
category = attr.ib(None, type=Optional[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,
|
||||
)
|
||||
221
fbchat/_threads/_user.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import attr
|
||||
import datetime
|
||||
from ._abc import ThreadABC
|
||||
from .._common import log, attrs_default
|
||||
from .. import _util, _session, _models
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
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=Optional[str])
|
||||
#: When the thread was last active / when the last message was sent
|
||||
last_active = attr.ib(None, type=Optional[datetime.datetime])
|
||||
#: Number of messages in the thread
|
||||
message_count = attr.ib(None, type=Optional[int])
|
||||
#: Set `Plan`
|
||||
plan = attr.ib(None, type=Optional[_models.PlanData])
|
||||
#: The profile URL. ``None`` for Messenger-only users
|
||||
url = attr.ib(None, type=Optional[str])
|
||||
#: The user's gender
|
||||
gender = attr.ib(None, type=Optional[str])
|
||||
#: From 0 to 1. How close the client is to the user
|
||||
affinity = attr.ib(None, type=Optional[float])
|
||||
#: The user's nickname
|
||||
nickname = attr.ib(None, type=Optional[str])
|
||||
#: The clients nickname, as seen by the user
|
||||
own_nickname = attr.ib(None, type=Optional[str])
|
||||
#: The message color
|
||||
color = attr.ib(None, type=Optional[str])
|
||||
#: The default emoji
|
||||
emoji = attr.ib(None, type=Optional[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"]),
|
||||
)
|
||||
168
fbchat/_util.py
Normal file
@@ -0,0 +1,168 @@
|
||||
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
|
||||
|
||||
|
||||
def int_or_none(inp: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(inp)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
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(now())
|
||||
value = int(random.random() * 4294967295)
|
||||
string = ("0000000000000000000000" + format(value, "b"))[-22:]
|
||||
msgs = format(ret, "b") + string
|
||||
return str(int(msgs, 2))
|
||||
|
||||
|
||||
def remove_version_from_module(module):
|
||||
return module.split("@", 1)[0]
|
||||
|
||||
|
||||
def get_jsmods_require(require) -> Mapping[str, Sequence[Any]]:
|
||||
rtn = {}
|
||||
for item in require:
|
||||
if len(item) == 1:
|
||||
(module,) = item
|
||||
rtn[remove_version_from_module(module)] = []
|
||||
continue
|
||||
module, method, requirements, arguments = item
|
||||
method = "{}.{}".format(remove_version_from_module(module), method)
|
||||
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())
|
||||
|
||||
|
||||
def now() -> datetime.datetime:
|
||||
"""The current time.
|
||||
|
||||
Similar to datetime.datetime.now(), but returns a non-naive datetime.
|
||||
"""
|
||||
return datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
1716
fbchat/client.py
@@ -1,286 +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.get('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]
|
||||
if len(info['participant_customizations']) > 1:
|
||||
rtn['own_nickname'] = info['participant_customizations'][1]
|
||||
elif info['participant_customizations'][1]['participant_id'] == _id:
|
||||
rtn['nickname'] = info['participant_customizations'][1]
|
||||
if len(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'),
|
||||
extensible_attachment=message.get('extensible_attachment')
|
||||
)
|
||||
|
||||
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
|
||||
230
fbchat/models.py
@@ -1,230 +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
|
||||
#: Timestamp of last message
|
||||
last_message_timestamp = str
|
||||
|
||||
def __init__(self, _type, uid, photo=None, name=None, last_message_timestamp=None):
|
||||
"""Represents a Facebook thread"""
|
||||
self.uid = str(uid)
|
||||
self.type = _type
|
||||
self.photo = photo
|
||||
self.name = name
|
||||
self.last_message_timestamp = last_message_timestamp
|
||||
|
||||
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
|
||||
#: An extensible attachment, e.g. share object
|
||||
extensible_attachment = dict
|
||||
|
||||
def __init__(self, uid, author=None, timestamp=None, is_read=None, reactions=[], text=None, mentions=[], sticker=None, attachments=[], extensible_attachment={}):
|
||||
"""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
|
||||
self.extensible_attachment = extensible_attachment
|
||||
|
||||
|
||||
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
173
fbchat/utils.py
@@ -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
|
||||
63
pyproject.toml
Normal file
@@ -0,0 +1,63 @@
|
||||
[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://git.karaolidis.com/karaolidis/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]
|
||||
Repository = "https://git.karaolidis.com/karaolidis/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",
|
||||
]
|
||||
10
pytest.ini
Normal file
@@ -0,0 +1,10 @@
|
||||
[pytest]
|
||||
xfail_strict = true
|
||||
markers =
|
||||
online: Online tests, that require a user account set up. Meant to be used \
|
||||
manually, to check whether Facebook has broken something.
|
||||
addopts =
|
||||
--strict
|
||||
-m "not online"
|
||||
testpaths = tests
|
||||
filterwarnings = error
|
||||
@@ -1,3 +0,0 @@
|
||||
requests
|
||||
lxml
|
||||
beautifulsoup4
|
||||
81
setup.py
@@ -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 IOError:
|
||||
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
@@ -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('té')
|
||||
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
@@ -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
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"email": "",
|
||||
"password": "",
|
||||
"user_thread_id": "",
|
||||
"group_thread_id": ""
|
||||
}
|
||||
175
tests/events/test_client_payload.py
Normal 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))
|
||||
78
tests/events/test_common.py
Normal 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})
|
||||
359
tests/events/test_delta_class.py
Normal file
@@ -0,0 +1,359 @@
|
||||
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_title_removed(session):
|
||||
data = {
|
||||
"irisSeqId": "11223344",
|
||||
"irisTags": ["DeltaThreadName", "is_from_iris_fanout"],
|
||||
"messageMetadata": {
|
||||
"actorFbId": "3456",
|
||||
"adminText": "You removed the group name.",
|
||||
"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": "",
|
||||
"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=None,
|
||||
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)
|
||||
958
tests/events/test_delta_type.py
Normal 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
@@ -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,
|
||||
)
|
||||
459
tests/models/test_attachment.py
Normal 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
@@ -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)
|
||||
96
tests/models/test_location.py
Normal 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)
|
||||
118
tests/models/test_message.py
Normal 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
@@ -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
@@ -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)
|
||||
49
tests/models/test_quick_reply.py
Normal 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
|
||||
)
|
||||
91
tests/models/test_sticker.py
Normal 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,
|
||||
}
|
||||
)
|
||||
67
tests/online/conftest.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import fbchat
|
||||
import pytest
|
||||
import logging
|
||||
import getpass
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session(pytestconfig):
|
||||
session_cookies = pytestconfig.cache.get("session_cookies", None)
|
||||
try:
|
||||
session = fbchat.Session.from_cookies(session_cookies)
|
||||
except fbchat.FacebookError:
|
||||
logging.exception("Error while logging in with cookies!")
|
||||
session = fbchat.Session.login(input("Email: "), getpass.getpass("Password: "))
|
||||
|
||||
yield session
|
||||
|
||||
pytestconfig.cache.set("session_cookies", session.get_cookies())
|
||||
|
||||
# TODO: Allow the main session object to be closed - and perhaps used in `with`?
|
||||
session._session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(session):
|
||||
return fbchat.Client(session=session)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def user(pytestconfig, session):
|
||||
user_id = pytestconfig.cache.get("user_id", None)
|
||||
if not user_id:
|
||||
user_id = input("A user you're chatting with's id: ")
|
||||
pytestconfig.cache.set("user_id", user_id)
|
||||
return fbchat.User(session=session, id=user_id)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def group(pytestconfig, session):
|
||||
group_id = pytestconfig.cache.get("group_id", None)
|
||||
if not group_id:
|
||||
group_id = input("A group you're chatting with's id: ")
|
||||
pytestconfig.cache.set("group_id", group_id)
|
||||
return fbchat.Group(session=session, id=group_id)
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
scope="session",
|
||||
params=[
|
||||
"user",
|
||||
"group",
|
||||
"self",
|
||||
pytest.param("invalid", marks=[pytest.mark.xfail()]),
|
||||
],
|
||||
)
|
||||
def any_thread(request, session, user, group):
|
||||
return {
|
||||
"user": user,
|
||||
"group": group,
|
||||
"self": session.user,
|
||||
"invalid": fbchat.Thread(session=session, id="0"),
|
||||
}[request.param]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listener(session):
|
||||
return fbchat.Listener(session=session, chat_on=False, foreground=False)
|
||||
116
tests/online/test_client.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import pytest
|
||||
import fbchat
|
||||
import os
|
||||
|
||||
pytestmark = pytest.mark.online
|
||||
|
||||
|
||||
def test_fetch(client):
|
||||
client.fetch_users()
|
||||
|
||||
|
||||
def test_search_for_users(client):
|
||||
list(client.search_for_users("test", 10))
|
||||
|
||||
|
||||
def test_search_for_pages(client):
|
||||
list(client.search_for_pages("test", 100))
|
||||
|
||||
|
||||
def test_search_for_groups(client):
|
||||
list(client.search_for_groups("test", 1000))
|
||||
|
||||
|
||||
def test_search_for_threads(client):
|
||||
list(client.search_for_threads("test", 1000))
|
||||
|
||||
with pytest.raises(fbchat.HTTPError, match="rate limited"):
|
||||
list(client.search_for_threads("test", 10000))
|
||||
|
||||
|
||||
def test_message_search(client):
|
||||
list(client.search_messages("test", 500))
|
||||
|
||||
|
||||
def test_fetch_thread_info(client):
|
||||
list(client.fetch_thread_info(["4"]))[0]
|
||||
|
||||
|
||||
def test_fetch_threads(client):
|
||||
list(client.fetch_threads(20))
|
||||
list(client.fetch_threads(200))
|
||||
|
||||
|
||||
def test_undocumented(client):
|
||||
client.fetch_unread()
|
||||
client.fetch_unseen()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_resource(pytestconfig):
|
||||
def get_resource_inner(filename):
|
||||
path = os.path.join(pytestconfig.rootdir, "tests", "resources", filename)
|
||||
return open(path, "rb")
|
||||
|
||||
return get_resource_inner
|
||||
|
||||
|
||||
def test_upload_and_fetch_image_url(client, open_resource):
|
||||
with open_resource("image.png") as f:
|
||||
((id, mimetype),) = client.upload([("image.png", f, "image/png")])
|
||||
assert mimetype == "image/png"
|
||||
|
||||
assert client.fetch_image_url(id).startswith("http")
|
||||
|
||||
|
||||
def test_upload_image(client, open_resource):
|
||||
with open_resource("image.png") as f:
|
||||
_ = client.upload([("image.png", f, "image/png")])
|
||||
|
||||
|
||||
def test_upload_many(client, open_resource):
|
||||
with open_resource("image.png") as f_png, open_resource(
|
||||
"image.jpg"
|
||||
) as f_jpg, open_resource("image.gif") as f_gif, open_resource(
|
||||
"file.json"
|
||||
) as f_json, open_resource(
|
||||
"file.txt"
|
||||
) as f_txt, open_resource(
|
||||
"audio.mp3"
|
||||
) as f_mp3, open_resource(
|
||||
"video.mp4"
|
||||
) as f_mp4:
|
||||
_ = client.upload(
|
||||
[
|
||||
("image.png", f_png, "image/png"),
|
||||
("image.jpg", f_jpg, "image/jpeg"),
|
||||
("image.gif", f_gif, "image/gif"),
|
||||
("file.json", f_json, "application/json"),
|
||||
("file.txt", f_txt, "text/plain"),
|
||||
("audio.mp3", f_mp3, "audio/mpeg"),
|
||||
("video.mp4", f_mp4, "video/mp4"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_mark_as_read(client, user, group):
|
||||
client.mark_as_read([user, group], fbchat._util.now())
|
||||
|
||||
|
||||
def test_mark_as_unread(client, user, group):
|
||||
client.mark_as_unread([user, group], fbchat._util.now())
|
||||
|
||||
|
||||
def test_move_threads(client, user, group):
|
||||
client.move_threads(fbchat.ThreadLocation.PENDING, [user, group])
|
||||
client.move_threads(fbchat.ThreadLocation.INBOX, [user, group])
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="need to have threads to delete")
|
||||
def test_delete_threads():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="need to have messages to delete")
|
||||
def test_delete_messages():
|
||||
pass
|
||||
42
tests/online/test_send.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
import fbchat
|
||||
|
||||
pytestmark = pytest.mark.online
|
||||
|
||||
|
||||
# TODO: Verify return values
|
||||
|
||||
|
||||
def test_wave(any_thread):
|
||||
assert any_thread.wave(True)
|
||||
assert any_thread.wave(False)
|
||||
|
||||
|
||||
def test_send_text(any_thread):
|
||||
assert any_thread.send_text("Test")
|
||||
|
||||
|
||||
def test_send_text_with_mention(any_thread):
|
||||
mention = fbchat.Mention(thread_id=any_thread.id, offset=5, length=8)
|
||||
assert any_thread.send_text("Test @mention", mentions=[mention])
|
||||
|
||||
|
||||
def test_send_emoji(any_thread):
|
||||
assert any_thread.send_emoji("😀", size=fbchat.EmojiSize.LARGE)
|
||||
|
||||
|
||||
def test_send_sticker(any_thread):
|
||||
assert any_thread.send_sticker("1889713947839631")
|
||||
|
||||
|
||||
def test_send_location(any_thread):
|
||||
any_thread.send_location(51.5287718, -0.2416815)
|
||||
|
||||
|
||||
def test_send_pinned_location(any_thread):
|
||||
any_thread.send_pinned_location(39.9390731, 116.117273)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="need a way to use the uploaded files from test_client.py")
|
||||
def test_send_files(any_thread):
|
||||
pass
|
||||
BIN
tests/resources/audio.mp3
Normal file
4
tests/resources/file.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"some": "data",
|
||||
"in": "here"
|
||||
}
|
||||
1
tests/resources/file.txt
Normal file
@@ -0,0 +1 @@
|
||||
This is just a text file
|
||||
BIN
tests/resources/image.gif
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
tests/resources/image.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
tests/resources/video.mp4
Normal file
10
tests/test_examples.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import pytest
|
||||
import py_compile
|
||||
import glob
|
||||
from os import path
|
||||
|
||||
|
||||
def test_examples_compiles():
|
||||
# Compiles the examples, to check for syntax errors
|
||||
for name in glob.glob(path.join(path.dirname(__file__), "../examples", "*.py")):
|
||||
py_compile.compile(name)
|
||||
237
tests/test_exception.py
Normal file
@@ -0,0 +1,237 @@
|
||||
import pytest
|
||||
import requests
|
||||
from fbchat import (
|
||||
FacebookError,
|
||||
HTTPError,
|
||||
ParseError,
|
||||
ExternalError,
|
||||
GraphQLError,
|
||||
InvalidParameters,
|
||||
NotLoggedIn,
|
||||
PleaseRefresh,
|
||||
)
|
||||
from fbchat._exception import (
|
||||
handle_payload_error,
|
||||
handle_graphql_errors,
|
||||
handle_http_error,
|
||||
handle_requests_error,
|
||||
)
|
||||
|
||||
|
||||
ERROR_DATA = [
|
||||
(
|
||||
PleaseRefresh,
|
||||
1357004,
|
||||
"Sorry, something went wrong",
|
||||
"Please try closing and re-opening your browser window.",
|
||||
),
|
||||
(
|
||||
InvalidParameters,
|
||||
1357031,
|
||||
"This content is no longer available",
|
||||
(
|
||||
"The content you requested cannot be displayed at the moment. It may be"
|
||||
" temporarily unavailable, the link you clicked on may have expired or you"
|
||||
" may not have permission to view this page."
|
||||
),
|
||||
),
|
||||
(
|
||||
InvalidParameters,
|
||||
1545010,
|
||||
"Messages Unavailable",
|
||||
(
|
||||
"Sorry, messages are temporarily unavailable."
|
||||
" Please try again in a few minutes."
|
||||
),
|
||||
),
|
||||
(
|
||||
ExternalError,
|
||||
1545026,
|
||||
"Unable to Attach File",
|
||||
(
|
||||
"The type of file you're trying to attach isn't allowed."
|
||||
" Please try again with a different format."
|
||||
),
|
||||
),
|
||||
(InvalidParameters, 1545003, "Invalid action", "You cannot perform that action."),
|
||||
(
|
||||
ExternalError,
|
||||
1545012,
|
||||
"Temporary Failure",
|
||||
"There was a temporary error, please try again.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exception,code,summary,description", ERROR_DATA)
|
||||
def test_handle_payload_error(exception, code, summary, description):
|
||||
data = {"error": code, "errorSummary": summary, "errorDescription": description}
|
||||
with pytest.raises(exception, match=r"#\d+ .+:"):
|
||||
handle_payload_error(data)
|
||||
|
||||
|
||||
def test_handle_not_logged_in_error():
|
||||
data = {
|
||||
"error": 1357001,
|
||||
"errorSummary": "Not logged in",
|
||||
"errorDescription": "Please log in to continue.",
|
||||
}
|
||||
with pytest.raises(NotLoggedIn, match="Not logged in"):
|
||||
handle_payload_error(data)
|
||||
|
||||
|
||||
def test_handle_payload_error_no_error():
|
||||
assert handle_payload_error({}) is None
|
||||
assert handle_payload_error({"payload": {"abc": ["Something", "else"]}}) is None
|
||||
|
||||
|
||||
def test_handle_graphql_crash():
|
||||
error = {
|
||||
"allow_user_retry": False,
|
||||
"api_error_code": -1,
|
||||
"code": 1675030,
|
||||
"debug_info": None,
|
||||
"description": "Error performing query.",
|
||||
"fbtrace_id": "ABCDEFG",
|
||||
"is_silent": False,
|
||||
"is_transient": False,
|
||||
"message": (
|
||||
'Errors while executing operation "MessengerThreadSharedLinks":'
|
||||
" At Query.message_thread: Field implementation threw an exception."
|
||||
" Check your server logs for more information."
|
||||
),
|
||||
"path": ["message_thread"],
|
||||
"query_path": None,
|
||||
"requires_reauth": False,
|
||||
"severity": "CRITICAL",
|
||||
"summary": "Query error",
|
||||
}
|
||||
with pytest.raises(
|
||||
GraphQLError, match="#1675030 Query error: Errors while executing"
|
||||
):
|
||||
handle_graphql_errors({"data": {"message_thread": None}, "errors": [error]})
|
||||
|
||||
|
||||
def test_handle_graphql_invalid_values():
|
||||
error = {
|
||||
"message": (
|
||||
'Invalid values provided for variables of operation "MessengerThreadlist":'
|
||||
' Value ""as"" cannot be used for variable "$limit": Expected an integer'
|
||||
' value, got "as".'
|
||||
),
|
||||
"severity": "CRITICAL",
|
||||
"code": 1675012,
|
||||
"api_error_code": None,
|
||||
"summary": "Your request couldn't be processed",
|
||||
"description": (
|
||||
"There was a problem with this request."
|
||||
" We're working on getting it fixed as soon as we can."
|
||||
),
|
||||
"is_silent": False,
|
||||
"is_transient": False,
|
||||
"requires_reauth": False,
|
||||
"allow_user_retry": False,
|
||||
"debug_info": None,
|
||||
"query_path": None,
|
||||
"fbtrace_id": "ABCDEFG",
|
||||
"www_request_id": "AABBCCDDEEFFGG",
|
||||
}
|
||||
msg = "#1675012 Your request couldn't be processed: Invalid values"
|
||||
with pytest.raises(GraphQLError, match=msg):
|
||||
handle_graphql_errors({"errors": [error]})
|
||||
|
||||
|
||||
def test_handle_graphql_no_message():
|
||||
error = {
|
||||
"code": 1675012,
|
||||
"api_error_code": None,
|
||||
"summary": "Your request couldn't be processed",
|
||||
"description": (
|
||||
"There was a problem with this request."
|
||||
" We're working on getting it fixed as soon as we can."
|
||||
),
|
||||
"is_silent": False,
|
||||
"is_transient": False,
|
||||
"requires_reauth": False,
|
||||
"allow_user_retry": False,
|
||||
"debug_info": None,
|
||||
"query_path": None,
|
||||
"fbtrace_id": "ABCDEFG",
|
||||
"www_request_id": "AABBCCDDEEFFGG",
|
||||
"sentry_block_user_info": None,
|
||||
"help_center_id": None,
|
||||
}
|
||||
msg = "#1675012 Your request couldn't be processed: "
|
||||
with pytest.raises(GraphQLError, match=msg):
|
||||
handle_graphql_errors({"errors": [error]})
|
||||
|
||||
|
||||
def test_handle_graphql_no_summary():
|
||||
error = {
|
||||
"message": (
|
||||
'Errors while executing operation "MessengerViewerContactMethods":'
|
||||
" At Query.viewer:Viewer.all_emails: Field implementation threw an"
|
||||
" exception. Check your server logs for more information."
|
||||
),
|
||||
"severity": "ERROR",
|
||||
"path": ["viewer", "all_emails"],
|
||||
}
|
||||
with pytest.raises(GraphQLError, match="Unknown error: Errors while executing"):
|
||||
handle_graphql_errors(
|
||||
{"data": {"viewer": {"user": None, "all_emails": []}}, "errors": [error]}
|
||||
)
|
||||
|
||||
|
||||
def test_handle_graphql_syntax_error():
|
||||
error = {
|
||||
"code": 1675001,
|
||||
"api_error_code": None,
|
||||
"summary": "Query Syntax Error",
|
||||
"description": "Syntax error.",
|
||||
"is_silent": True,
|
||||
"is_transient": False,
|
||||
"requires_reauth": False,
|
||||
"allow_user_retry": False,
|
||||
"debug_info": 'Unexpected ">" at character 328: Expected ")".',
|
||||
"query_path": None,
|
||||
"fbtrace_id": "ABCDEFG",
|
||||
"www_request_id": "AABBCCDDEEFFGG",
|
||||
"sentry_block_user_info": None,
|
||||
"help_center_id": None,
|
||||
}
|
||||
msg = "#1675001 Query Syntax Error: "
|
||||
with pytest.raises(GraphQLError, match=msg):
|
||||
handle_graphql_errors({"response": None, "error": error})
|
||||
|
||||
|
||||
def test_handle_graphql_errors_singular_error_key():
|
||||
with pytest.raises(GraphQLError, match="#123"):
|
||||
handle_graphql_errors({"error": {"code": 123}})
|
||||
|
||||
|
||||
def test_handle_graphql_errors_no_error():
|
||||
assert handle_graphql_errors({"data": {"message_thread": None}}) is None
|
||||
|
||||
|
||||
def test_handle_http_error():
|
||||
with pytest.raises(HTTPError):
|
||||
handle_http_error(400)
|
||||
with pytest.raises(HTTPError):
|
||||
handle_http_error(500)
|
||||
|
||||
|
||||
def test_handle_http_error_404_handling():
|
||||
with pytest.raises(HTTPError, match="invalid id"):
|
||||
handle_http_error(404)
|
||||
|
||||
|
||||
def test_handle_http_error_no_error():
|
||||
assert handle_http_error(200) is None
|
||||
assert handle_http_error(302) is None
|
||||
|
||||
|
||||
def test_handle_requests_error():
|
||||
with pytest.raises(HTTPError, match="Connection error"):
|
||||
handle_requests_error(requests.ConnectionError())
|
||||
with pytest.raises(HTTPError, match="Requests error"):
|
||||
handle_requests_error(requests.RequestException())
|
||||
35
tests/test_graphql.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
import json
|
||||
from fbchat._graphql import ConcatJSONDecoder, queries_to_json, response_to_json
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,result",
|
||||
[
|
||||
("", []),
|
||||
('{"a":"b"}', [{"a": "b"}]),
|
||||
('{"a":"b"}{"b":"c"}', [{"a": "b"}, {"b": "c"}]),
|
||||
(' \n{"a": "b" } \n { "b" \n\n : "c" }', [{"a": "b"}, {"b": "c"}]),
|
||||
],
|
||||
)
|
||||
def test_concat_json_decoder(text, result):
|
||||
assert result == json.loads(text, cls=ConcatJSONDecoder)
|
||||
|
||||
|
||||
def test_queries_to_json():
|
||||
assert {"q0": "A", "q1": "B", "q2": "C"} == json.loads(
|
||||
queries_to_json("A", "B", "C")
|
||||
)
|
||||
|
||||
|
||||
def test_response_to_json():
|
||||
data = (
|
||||
'{"q1":{"data":{"b":"c"}}}\r\n'
|
||||
'{"q0":{"response":[1,2]}}\r\n'
|
||||
"{\n"
|
||||
' "successful_results": 2,\n'
|
||||
' "error_results": 0,\n'
|
||||
' "skipped_results": 0\n'
|
||||
"}"
|
||||
)
|
||||
assert [[1, 2], {"b": "c"}] == response_to_json(data)
|
||||
16
tests/test_module_renaming.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import fbchat
|
||||
|
||||
|
||||
def test_module_renaming():
|
||||
assert fbchat.Message.__module__ == "fbchat"
|
||||
assert fbchat.Group.__module__ == "fbchat"
|
||||
assert fbchat.Event.__module__ == "fbchat"
|
||||
assert fbchat.User.block.__module__ == "fbchat"
|
||||
assert fbchat.Session.login.__func__.__module__ == "fbchat"
|
||||
assert fbchat.Session._from_session.__func__.__module__ == "fbchat"
|
||||
assert fbchat.Message.session.fget.__module__ == "fbchat"
|
||||
assert fbchat.Session.__repr__.__module__ == "fbchat"
|
||||
|
||||
|
||||
def test_did_not_rename():
|
||||
assert fbchat._graphql.queries_to_json.__module__ != "fbchat"
|
||||
190
tests/test_session.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from fbchat import ParseError, _util
|
||||
from fbchat._session import (
|
||||
parse_server_js_define,
|
||||
base36encode,
|
||||
prefix_url,
|
||||
generate_message_id,
|
||||
session_factory,
|
||||
client_id_factory,
|
||||
find_form_request,
|
||||
get_error_data,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_server_js_define_old():
|
||||
html = """
|
||||
some data;require("TimeSliceImpl").guard(function(){(require("ServerJSDefine")).handleDefines([["DTSGInitialData",[],{"token":"123"},100]])
|
||||
|
||||
<script>require("TimeSliceImpl").guard(function() {require("ServerJSDefine").handleDefines([["DTSGInitData",[],{"token":"123","async_get_token":"12345"},3333]])
|
||||
|
||||
</script>
|
||||
other irrelevant data
|
||||
"""
|
||||
define = parse_server_js_define(html)
|
||||
assert define == {
|
||||
"DTSGInitialData": {"token": "123"},
|
||||
"DTSGInitData": {"async_get_token": "12345", "token": "123"},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_server_js_define_new():
|
||||
html = """
|
||||
some data;require("TimeSliceImpl").guard(function(){new (require("ServerJS"))().handle({"define":[["DTSGInitialData",[],{"token":""},100]],"require":[...]});}, "ServerJS define", {"root":true})();
|
||||
more data
|
||||
<script><script>require("TimeSliceImpl").guard(function(){var s=new (require("ServerJS"))();s.handle({"define":[["DTSGInitData",[],{"token":"","async_get_token":""},3333]],"require":[...]});require("Run").onAfterLoad(function(){s.cleanup(require("TimeSliceImpl"))});}, "ServerJS define", {"root":true})();</script>
|
||||
other irrelevant data
|
||||
"""
|
||||
define = parse_server_js_define(html)
|
||||
assert define == {
|
||||
"DTSGInitialData": {"token": ""},
|
||||
"DTSGInitData": {"async_get_token": "", "token": ""},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_server_js_define_error():
|
||||
with pytest.raises(ParseError, match="Could not find any"):
|
||||
parse_server_js_define("")
|
||||
|
||||
html = 'function(){(require("ServerJSDefine")).handleDefines([{"a": function(){}}])'
|
||||
with pytest.raises(ParseError, match="Invalid"):
|
||||
parse_server_js_define(html + html)
|
||||
|
||||
html = 'function(){require("ServerJSDefine").handleDefines({"a": "b"})'
|
||||
with pytest.raises(ParseError, match="Invalid"):
|
||||
parse_server_js_define(html + html)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"number,expected",
|
||||
[(1, "1"), (10, "a"), (123, "3f"), (1000, "rs"), (123456789, "21i3v9")],
|
||||
)
|
||||
def test_base36encode(number, expected):
|
||||
assert base36encode(number) == expected
|
||||
|
||||
|
||||
def test_prefix_url():
|
||||
static_url = "https://upload.messenger.com/"
|
||||
assert prefix_url(static_url) == static_url
|
||||
assert prefix_url("/") == "https://www.messenger.com/"
|
||||
assert prefix_url("/abc") == "https://www.messenger.com/abc"
|
||||
|
||||
|
||||
def test_generate_message_id():
|
||||
# Returns random output, so hard to test more thoroughly
|
||||
assert generate_message_id(_util.now(), "def")
|
||||
|
||||
|
||||
def test_session_factory():
|
||||
session = session_factory()
|
||||
assert session.headers
|
||||
|
||||
|
||||
def test_client_id_factory():
|
||||
# Returns random output, so hard to test more thoroughly
|
||||
assert client_id_factory()
|
||||
|
||||
|
||||
def test_find_form_request():
|
||||
html = """
|
||||
<div>
|
||||
<form action="/checkpoint/?next=https%3A%2F%2Fwww.messenger.com%2F" class="checkpoint" id="u_0_c" method="post" onsubmit="">
|
||||
<input autocomplete="off" name="jazoest" type="hidden" value="some-number" />
|
||||
<input autocomplete="off" name="fb_dtsg" type="hidden" value="some-base64" />
|
||||
<input class="hidden_elem" data-default-submit="true" name="submit[Continue]" type="submit" />
|
||||
<input autocomplete="off" name="nh" type="hidden" value="some-hex" />
|
||||
<div class="_4-u2 _5x_7 _p0k _5x_9 _4-u8">
|
||||
<div class="_2e9n" id="u_0_d">
|
||||
<strong id="u_0_e">Two factor authentication required</strong>
|
||||
<div id="u_0_f"></div>
|
||||
</div>
|
||||
<div class="_2ph_">
|
||||
<input autocomplete="off" name="no_fido" type="hidden" value="true" />
|
||||
<div class="_50f4">You've asked us to require a 6-digit login code when anyone tries to access your account from a new device or browser.</div>
|
||||
<div class="_3-8y _50f4">Enter the 6-digit code from your Code Generator or 3rd party app below.</div>
|
||||
<div class="_2pie _2pio">
|
||||
<span>
|
||||
<input aria-label="Login code" autocomplete="off" class="inputtext" id="approvals_code" name="approvals_code" placeholder="Login code" tabindex="1" type="text" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_5hzs" id="checkpointBottomBar">
|
||||
<div class="_2s5p">
|
||||
<button class="_42ft _4jy0 _2kak _4jy4 _4jy1 selected _51sy" id="checkpointSubmitButton" name="submit[Continue]" type="submit" value="Continue">Continue</button>
|
||||
</div>
|
||||
<div class="_2s5q">
|
||||
<div class="_25b6" id="u_0_g">
|
||||
<a href="#" id="u_0_h" role="button">Need another way to authenticate?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
url, data = find_form_request(html)
|
||||
assert url.startswith("https://www.facebook.com/checkpoint/")
|
||||
assert {
|
||||
"jazoest": "some-number",
|
||||
"fb_dtsg": "some-base64",
|
||||
"nh": "some-hex",
|
||||
"no_fido": "true",
|
||||
"approvals_code": "[missing]",
|
||||
"submit[Continue]": "Continue",
|
||||
} == data
|
||||
|
||||
|
||||
def test_find_form_request_error():
|
||||
with pytest.raises(ParseError, match="Could not find form to submit"):
|
||||
assert find_form_request("")
|
||||
with pytest.raises(ParseError, match="Could not find url to submit to"):
|
||||
assert find_form_request("<form></form>")
|
||||
|
||||
|
||||
def test_get_error_data():
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="da" id="facebook" class="no_js">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title id="pageTitle">Messenger</title>
|
||||
<meta name="referrer" content="default" id="meta_referrer" />
|
||||
</head>
|
||||
|
||||
<body class="_605a x1 Locale_da_DK" dir="ltr">
|
||||
<div class="_3v_o" id="XMessengerDotComLoginViewPlaceholder">
|
||||
<form id="login_form" action="/login/password/" method="post" onsubmit="">
|
||||
<input type="hidden" name="jazoest" value="2222" autocomplete="off" />
|
||||
<input type="hidden" name="lsd" value="xyz-abc" autocomplete="off" />
|
||||
<div class="_3403 _3404">
|
||||
<div>Type your password again</div>
|
||||
<div>The password you entered is incorrect. <a href="https://www.facebook.com/recover/initiate?ars=facebook_login_pw_error">Did you forget your password?</a></div>
|
||||
</div>
|
||||
<div id="loginform">
|
||||
<input type="hidden" autocomplete="off" id="initial_request_id" name="initial_request_id" value="xxx" />
|
||||
<input type="hidden" autocomplete="off" name="timezone" value="" id="u_0_1" />
|
||||
<input type="hidden" autocomplete="off" name="lgndim" value="" id="u_0_2" />
|
||||
<input type="hidden" name="lgnrnd" value="aaa" />
|
||||
<input type="hidden" id="lgnjs" name="lgnjs" value="n" />
|
||||
<input type="text" class="inputtext _55r1 _43di" id="email" name="email" placeholder="E-mail or phone number" value="some@email.com" tabindex="0" aria-label="E-mail or phone number" />
|
||||
<input type="password" class="inputtext _55r1 _43di" name="pass" id="pass" tabindex="0" placeholder="Password" aria-label="Password" />
|
||||
<button value="1" class="_42ft _4jy0 _2m_r _43dh _4jy4 _517h _51sy" id="loginbutton" name="login" tabindex="0" type="submit">Continue</button>
|
||||
<div class="_43dj">
|
||||
<div class="uiInputLabel clearfix">
|
||||
<label class="uiInputLabelInput">
|
||||
<input type="checkbox" value="1" name="persistent" tabindex="0" class="" id="u_0_0" />
|
||||
<span class=""></span>
|
||||
</label>
|
||||
<label for="u_0_0" class="uiInputLabelLabel">Stay logged in</label>
|
||||
</div>
|
||||
<input type="hidden" autocomplete="off" id="default_persistent" name="default_persistent" value="0" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
"""
|
||||
msg = "The password you entered is incorrect. Did you forget your password?"
|
||||
assert msg == get_error_data(html)
|
||||
247
tests/test_util.py
Normal file
@@ -0,0 +1,247 @@
|
||||
import pytest
|
||||
import fbchat
|
||||
import datetime
|
||||
from fbchat._util import (
|
||||
strip_json_cruft,
|
||||
parse_json,
|
||||
get_jsmods_require,
|
||||
get_jsmods_define,
|
||||
mimetype_to_key,
|
||||
get_url_parameter,
|
||||
seconds_to_datetime,
|
||||
millis_to_datetime,
|
||||
datetime_to_seconds,
|
||||
datetime_to_millis,
|
||||
seconds_to_timedelta,
|
||||
millis_to_timedelta,
|
||||
timedelta_to_seconds,
|
||||
)
|
||||
|
||||
|
||||
def test_strip_json_cruft():
|
||||
assert strip_json_cruft('for(;;);{"abc": "def"}') == '{"abc": "def"}'
|
||||
assert strip_json_cruft('{"abc": "def"}') == '{"abc": "def"}'
|
||||
|
||||
|
||||
def test_strip_json_cruft_invalid():
|
||||
with pytest.raises(AttributeError):
|
||||
strip_json_cruft(None)
|
||||
with pytest.raises(fbchat.ParseError, match="No JSON object found"):
|
||||
strip_json_cruft("No JSON object here!")
|
||||
|
||||
|
||||
def test_parse_json():
|
||||
assert parse_json('{"a":"b"}') == {"a": "b"}
|
||||
|
||||
|
||||
def test_parse_json_invalid():
|
||||
with pytest.raises(fbchat.ParseError, match="Error while parsing JSON"):
|
||||
parse_json("No JSON object here!")
|
||||
|
||||
|
||||
def test_get_jsmods_require():
|
||||
argument = {
|
||||
"signalsToCollect": [
|
||||
30000,
|
||||
30001,
|
||||
30003,
|
||||
30004,
|
||||
30005,
|
||||
30002,
|
||||
30007,
|
||||
30008,
|
||||
30009,
|
||||
]
|
||||
}
|
||||
data = [
|
||||
["BanzaiODS"],
|
||||
[
|
||||
"TuringClientSignalCollectionTrigger",
|
||||
"startStaticSignalCollection",
|
||||
[],
|
||||
[argument],
|
||||
],
|
||||
]
|
||||
assert get_jsmods_require(data) == {
|
||||
"BanzaiODS": [],
|
||||
"TuringClientSignalCollectionTrigger.startStaticSignalCollection": [argument],
|
||||
}
|
||||
|
||||
|
||||
def test_get_jsmods_require_version_specifier():
|
||||
data = [
|
||||
["DimensionTracking@1234"],
|
||||
["CavalryLoggerImpl@2345", "startInstrumentation", [], []],
|
||||
]
|
||||
assert get_jsmods_require(data) == {
|
||||
"DimensionTracking": [],
|
||||
"CavalryLoggerImpl.startInstrumentation": [],
|
||||
}
|
||||
|
||||
|
||||
def test_get_jsmods_require_get_image_url():
|
||||
data = [
|
||||
[
|
||||
"ServerRedirect",
|
||||
"redirectPageTo",
|
||||
[],
|
||||
["https://scontent-arn2-1.xx.fbcdn.net/v/image.png&dl=1", False, False],
|
||||
],
|
||||
["TuringClientSignalCollectionTrigger", "...", [], [...]],
|
||||
["TuringClientSignalCollectionTrigger", "retrieveSignals", [], [...]],
|
||||
["BanzaiODS"],
|
||||
["BanzaiScuba"],
|
||||
]
|
||||
url = "https://scontent-arn2-1.xx.fbcdn.net/v/image.png&dl=1"
|
||||
assert get_jsmods_require(data)["ServerRedirect.redirectPageTo"][0] == url
|
||||
|
||||
|
||||
def test_get_jsmods_define():
|
||||
data = [
|
||||
[
|
||||
"BootloaderConfig",
|
||||
[],
|
||||
{
|
||||
"jsRetries": [200, 500],
|
||||
"jsRetryAbortNum": 2,
|
||||
"jsRetryAbortTime": 5,
|
||||
"payloadEndpointURI": "https://www.facebook.com/ajax/bootloader-endpoint/",
|
||||
"preloadBE": False,
|
||||
"assumeNotNonblocking": True,
|
||||
"shouldCoalesceModuleRequestsMadeInSameTick": True,
|
||||
"staggerJsDownloads": False,
|
||||
"preloader_num_preloads": 0,
|
||||
"preloader_preload_after_dd": False,
|
||||
"preloader_num_loads": 1,
|
||||
"preloader_enabled": False,
|
||||
"retryQueuedBootloads": False,
|
||||
"silentDups": False,
|
||||
"asyncPreloadBoost": True,
|
||||
},
|
||||
123,
|
||||
],
|
||||
[
|
||||
"CSSLoaderConfig",
|
||||
[],
|
||||
{"timeout": 5000, "modulePrefix": "BLCSS:", "loadEventSupported": True},
|
||||
456,
|
||||
],
|
||||
["CurrentCommunityInitialData", [], {}, 789],
|
||||
[
|
||||
"CurrentEnvironment",
|
||||
[],
|
||||
{"facebookdotcom": True, "messengerdotcom": False},
|
||||
987,
|
||||
],
|
||||
]
|
||||
assert get_jsmods_define(data) == {
|
||||
"BootloaderConfig": {
|
||||
"jsRetries": [200, 500],
|
||||
"jsRetryAbortNum": 2,
|
||||
"jsRetryAbortTime": 5,
|
||||
"payloadEndpointURI": "https://www.facebook.com/ajax/bootloader-endpoint/",
|
||||
"preloadBE": False,
|
||||
"assumeNotNonblocking": True,
|
||||
"shouldCoalesceModuleRequestsMadeInSameTick": True,
|
||||
"staggerJsDownloads": False,
|
||||
"preloader_num_preloads": 0,
|
||||
"preloader_preload_after_dd": False,
|
||||
"preloader_num_loads": 1,
|
||||
"preloader_enabled": False,
|
||||
"retryQueuedBootloads": False,
|
||||
"silentDups": False,
|
||||
"asyncPreloadBoost": True,
|
||||
},
|
||||
"CSSLoaderConfig": {
|
||||
"timeout": 5000,
|
||||
"modulePrefix": "BLCSS:",
|
||||
"loadEventSupported": True,
|
||||
},
|
||||
"CurrentCommunityInitialData": {},
|
||||
"CurrentEnvironment": {"facebookdotcom": True, "messengerdotcom": False},
|
||||
}
|
||||
|
||||
|
||||
def test_get_jsmods_define_get_fb_dtsg():
|
||||
data = [
|
||||
["DTSGInitialData", [], {"token": "AQG-abcdefgh:AQGijklmnopq"}, 258],
|
||||
[
|
||||
"DTSGInitData",
|
||||
[],
|
||||
{"token": "AQG-abcdefgh:AQGijklmnopq", "async_get_token": "ABC123:DEF456"},
|
||||
3515,
|
||||
],
|
||||
]
|
||||
jsmods = get_jsmods_define(data)
|
||||
assert (
|
||||
jsmods["DTSGInitData"]["token"]
|
||||
== jsmods["DTSGInitialData"]["token"]
|
||||
== "AQG-abcdefgh:AQGijklmnopq"
|
||||
)
|
||||
|
||||
|
||||
def test_mimetype_to_key():
|
||||
assert mimetype_to_key(None) == "file_id"
|
||||
assert mimetype_to_key("image/gif") == "gif_id"
|
||||
assert mimetype_to_key("video/mp4") == "video_id"
|
||||
assert mimetype_to_key("video/quicktime") == "video_id"
|
||||
assert mimetype_to_key("image/png") == "image_id"
|
||||
assert mimetype_to_key("image/jpeg") == "image_id"
|
||||
assert mimetype_to_key("audio/mpeg") == "audio_id"
|
||||
assert mimetype_to_key("application/json") == "file_id"
|
||||
|
||||
|
||||
def test_get_url_parameter():
|
||||
assert get_url_parameter("http://example.com?a=b&c=d", "c") == "d"
|
||||
assert get_url_parameter("http://example.com?a=b&a=c", "a") == "b"
|
||||
assert get_url_parameter("http://example.com", "a") is None
|
||||
|
||||
|
||||
DT_0 = datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
|
||||
DT = datetime.datetime(2018, 11, 16, 1, 51, 4, 162000, tzinfo=datetime.timezone.utc)
|
||||
DT_NO_TIMEZONE = datetime.datetime(2018, 11, 16, 1, 51, 4, 162000)
|
||||
|
||||
|
||||
def test_seconds_to_datetime():
|
||||
assert seconds_to_datetime(0) == DT_0
|
||||
assert seconds_to_datetime(1542333064.162) == DT
|
||||
assert seconds_to_datetime(1542333064.162) != DT_NO_TIMEZONE
|
||||
|
||||
|
||||
def test_millis_to_datetime():
|
||||
assert millis_to_datetime(0) == DT_0
|
||||
assert millis_to_datetime(1542333064162) == DT
|
||||
assert millis_to_datetime(1542333064162) != DT_NO_TIMEZONE
|
||||
|
||||
|
||||
def test_datetime_to_seconds():
|
||||
assert datetime_to_seconds(DT_0) == 0
|
||||
assert datetime_to_seconds(DT) == 1542333064 # Rounded
|
||||
datetime_to_seconds(DT_NO_TIMEZONE) # Depends on system timezone
|
||||
|
||||
|
||||
def test_datetime_to_millis():
|
||||
assert datetime_to_millis(DT_0) == 0
|
||||
assert datetime_to_millis(DT) == 1542333064162
|
||||
datetime_to_millis(DT_NO_TIMEZONE) # Depends on system timezone
|
||||
|
||||
|
||||
def test_seconds_to_timedelta():
|
||||
assert seconds_to_timedelta(0.001) == datetime.timedelta(microseconds=1000)
|
||||
assert seconds_to_timedelta(1) == datetime.timedelta(seconds=1)
|
||||
assert seconds_to_timedelta(3600) == datetime.timedelta(hours=1)
|
||||
assert seconds_to_timedelta(86400) == datetime.timedelta(days=1)
|
||||
|
||||
|
||||
def test_millis_to_timedelta():
|
||||
assert millis_to_timedelta(1) == datetime.timedelta(microseconds=1000)
|
||||
assert millis_to_timedelta(1000) == datetime.timedelta(seconds=1)
|
||||
assert millis_to_timedelta(3600000) == datetime.timedelta(hours=1)
|
||||
assert millis_to_timedelta(86400000) == datetime.timedelta(days=1)
|
||||
|
||||
|
||||
def test_timedelta_to_seconds():
|
||||
assert timedelta_to_seconds(datetime.timedelta(microseconds=1000)) == 0 # Rounded
|
||||
assert timedelta_to_seconds(datetime.timedelta(seconds=1)) == 1
|
||||
assert timedelta_to_seconds(datetime.timedelta(hours=1)) == 3600
|
||||
assert timedelta_to_seconds(datetime.timedelta(days=1)) == 86400
|
||||