Compare commits
	
		
			840 Commits
		
	
	
		
			v1.0.24
			...
			8ac6dc4ae6
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 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 | ||
|  | 34452f9220 | ||
|  | 24831b2462 | ||
|  | 1beb821b2c | ||
|  | a58791048a | 
							
								
								
									
										34
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | --- | ||||||
|  | name: Bug report | ||||||
|  | about: Create a report if you're having trouble with `fbchat` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Description of the problem | ||||||
|  | Example: Logging in fails when the character `%` is in the password. A specific password that fails is `a_password_with_%` | ||||||
|  |  | ||||||
|  | ## Code to reproduce | ||||||
|  | ```py | ||||||
|  | # Example code | ||||||
|  | from fbchat import Client | ||||||
|  | client = Client("[REDACTED_USERNAME]", "a_password_with_%") | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Traceback | ||||||
|  | ``` | ||||||
|  | Traceback (most recent call last): | ||||||
|  |   File "<test.py>", line 1, in <module> | ||||||
|  |   File "[site-packages]/fbchat/client.py", line 78, in __init__ | ||||||
|  |     self.login(email, password, max_tries) | ||||||
|  |   File "[site-packages]/fbchat/client.py", line 407, in login | ||||||
|  |     raise FBchatException('Login failed. Check email/password. (Failed on URL: {})'.format(login_url)) | ||||||
|  | fbchat.FBchatException: Login failed. Check email/password. (Failed on URL: https://m.facebook.com/login.php?login_attempt=1) | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Environment information | ||||||
|  | - Python version | ||||||
|  | - `fbchat` version | ||||||
|  | - If relevant, output from `$ python -m pip list` | ||||||
|  |  | ||||||
|  | If you have done any research, include that. | ||||||
|  | Make sure to redact all personal information. | ||||||
							
								
								
									
										19
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | --- | ||||||
|  | name: Feature request | ||||||
|  | about: Suggest a feature that you'd like to see implemented | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Description | ||||||
|  | Example: There's no way to send messages to groups | ||||||
|  |  | ||||||
|  | ## Research (if applicable) | ||||||
|  | Example: I've found the URL `https://facebook.com/send_message.php`, to which you can send a POST requests with the following JSON: | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |    "text": message_content, | ||||||
|  |    "fbid": group_id, | ||||||
|  |    "some_variable": ? | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  | But I don't know how what `some_variable` does, and it doesn't work without it. I've found some examples of `some_variable` to be: `MTIzNDU2Nzg5MA`, `MTIzNDU2Nzg5MQ` and `MTIzNDU2Nzg5Mg` | ||||||
							
								
								
									
										13
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -8,9 +8,11 @@ | |||||||
| # Packages | # Packages | ||||||
| *.egg | *.egg | ||||||
| *.egg-info | *.egg-info | ||||||
|  | *.dist-info | ||||||
| dist | dist | ||||||
| build | build | ||||||
| eggs | eggs | ||||||
|  | .eggs | ||||||
| parts | parts | ||||||
| bin | bin | ||||||
| var | var | ||||||
| @@ -24,7 +26,16 @@ develop-eggs | |||||||
| # Sphinx documentation | # Sphinx documentation | ||||||
| docs/_build/ | docs/_build/ | ||||||
|  |  | ||||||
| # Data for tests | # Scripts and data for tests | ||||||
|  | my_tests.py | ||||||
| my_test_data.json | my_test_data.json | ||||||
| my_data.json | my_data.json | ||||||
| tests.data | tests.data | ||||||
|  | .pytest_cache | ||||||
|  |  | ||||||
|  | # MyPy | ||||||
|  | .mypy_cache/ | ||||||
|  |  | ||||||
|  | # Virtual environment | ||||||
|  | venv/ | ||||||
|  | .venv*/ | ||||||
|   | |||||||
							
								
								
									
										20
									
								
								.readthedocs.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.readthedocs.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details | ||||||
|  | version: 2 | ||||||
|  |  | ||||||
|  | formats: | ||||||
|  |   - pdf | ||||||
|  |   - htmlzip | ||||||
|  |  | ||||||
|  | python: | ||||||
|  |   version: 3.6 | ||||||
|  |   install: | ||||||
|  |     - path: . | ||||||
|  |       extra_requirements: | ||||||
|  |         - docs | ||||||
|  |  | ||||||
|  | # Build documentation in the docs/ directory with Sphinx | ||||||
|  | sphinx: | ||||||
|  |   configuration: docs/conf.py | ||||||
|  |   # Disabled, until we can find a way to get sphinx-autodoc-typehints play nice with our | ||||||
|  |   # module renaming! | ||||||
|  |   fail_on_warning: false | ||||||
							
								
								
									
										53
									
								
								.travis.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								.travis.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | sudo: false | ||||||
|  | language: python | ||||||
|  | python: 3.6 | ||||||
|  |  | ||||||
|  | cache: pip | ||||||
|  |  | ||||||
|  | before_install: pip install flit | ||||||
|  | # Use `--deps production` so that we don't install unnecessary dependencies | ||||||
|  | install: flit install --deps production --extras test | ||||||
|  | script: pytest | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   include: | ||||||
|  |   - python: 3.5 | ||||||
|  |   - python: 3.6 | ||||||
|  |   - python: 3.7 | ||||||
|  |   - python: pypy3.5 | ||||||
|  |  | ||||||
|  |   - name: Lint | ||||||
|  |     before_install: skip | ||||||
|  |     install: pip install black | ||||||
|  |     script: black --check --verbose . | ||||||
|  |  | ||||||
|  |   - stage: deploy | ||||||
|  |     name: GitHub Releases | ||||||
|  |     if: tag IS present | ||||||
|  |     install: skip | ||||||
|  |     script: flit build | ||||||
|  |     deploy: | ||||||
|  |       provider: releases | ||||||
|  |       api_key: $GITHUB_OAUTH_TOKEN | ||||||
|  |       file_glob: true | ||||||
|  |       file: dist/* | ||||||
|  |       skip_cleanup: true | ||||||
|  |       draft: false | ||||||
|  |       on: | ||||||
|  |         tags: true | ||||||
|  |  | ||||||
|  |   - stage: deploy | ||||||
|  |     name: PyPI | ||||||
|  |     if: tag IS present | ||||||
|  |     install: skip | ||||||
|  |     script: skip | ||||||
|  |     deploy: | ||||||
|  |       provider: script | ||||||
|  |       script: flit publish | ||||||
|  |       on: | ||||||
|  |         tags: true | ||||||
|  |  | ||||||
|  | notifications: | ||||||
|  |   email: | ||||||
|  |     on_success: never | ||||||
|  |     on_failure: change | ||||||
							
								
								
									
										75
									
								
								CODE_OF_CONDUCT
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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 | Copyright (c) 2015, Taehoon Kim | ||||||
| All rights reserved. | 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 |   this list of conditions and the following disclaimer in the documentation | ||||||
|   and/or other materials provided with the distribution. |   and/or other materials provided with the distribution. | ||||||
| 
 | 
 | ||||||
| * The names of its contributors may not be used to endorse or promote products | * Neither the name of the copyright holder nor the names of its | ||||||
|   derived from this software without specific prior written permission. |   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" | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | ||||||
| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | 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 |  | ||||||
							
								
								
									
										116
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										116
									
								
								README.rst
									
									
									
									
									
								
							| @@ -1,30 +1,112 @@ | |||||||
| fbchat: Facebook Chat (Messenger) for Python | ``fbchat`` - Facebook Messenger for Python | ||||||
| ============================================ | ========================================== | ||||||
|  |  | ||||||
| .. image:: https://img.shields.io/badge/license-BSD-blue.svg | .. image:: https://badgen.net/pypi/v/fbchat | ||||||
|     :target: LICENSE.txt |  | ||||||
|     :alt: License: BSD |  | ||||||
|  |  | ||||||
| .. image:: https://img.shields.io/badge/python-2.7%2C%203.4%2C%203.5%2C%203.6-blue.svg |  | ||||||
|     :target: https://pypi.python.org/pypi/fbchat |     :target: https://pypi.python.org/pypi/fbchat | ||||||
|     :alt: Supported python versions: 2.7, 3.4, 3.5 and 3.6 |     :alt: Project version | ||||||
|  |  | ||||||
| .. image:: https://readthedocs.org/projects/fbchat/badge/?version=master | .. image:: https://badgen.net/badge/python/3.5,3.6,3.7,3.8,pypy?list=| | ||||||
|  |     :target: https://pypi.python.org/pypi/fbchat | ||||||
|  |     :alt: Supported python versions: 3.5, 3.6, 3.7, 3.8 and pypy | ||||||
|  |  | ||||||
|  | .. image:: https://badgen.net/pypi/license/fbchat | ||||||
|  |     :target: https://github.com/carpedm20/fbchat/tree/master/LICENSE | ||||||
|  |     :alt: License: BSD 3-Clause | ||||||
|  |  | ||||||
|  | .. image:: https://readthedocs.org/projects/fbchat/badge/?version=stable | ||||||
|     :target: https://fbchat.readthedocs.io |     :target: https://fbchat.readthedocs.io | ||||||
|     :alt: Documentation |     :alt: Documentation | ||||||
|  |  | ||||||
| Facebook Chat (`Messenger <https://www.facebook.com/messages/>`__) for Python. | .. image:: https://badgen.net/travis/carpedm20/fbchat | ||||||
| This project was inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`__. |     :target: https://travis-ci.org/carpedm20/fbchat | ||||||
|  |     :alt: Travis CI | ||||||
|  |  | ||||||
| **No XMPP or API key is needed**. Just use your email and password. | .. image:: https://badgen.net/badge/code%20style/black/black | ||||||
|  |     :target: https://github.com/ambv/black | ||||||
|  |     :alt: Code style | ||||||
|  |  | ||||||
| Go to `Read the Docs <https://fbchat.readthedocs.io>`__ to see the full documentation, | A powerful and efficient library to interact with | ||||||
| or jump right into the code by viewing the `examples <examples>`__ | `Facebook's Messenger <https://www.facebook.com/messages/>`__, using just your email and password. | ||||||
|  |  | ||||||
| Installation: | This is *not* an official API, Facebook has that `over here <https://developers.facebook.com/docs/messenger-platform>`__ for chat bots. This library differs by using a normal Facebook account instead. | ||||||
|  |  | ||||||
| .. code-block:: console | ``fbchat`` currently support: | ||||||
|  |  | ||||||
|  | - Sending many types of messages, with files, stickers, mentions, etc. | ||||||
|  | - Fetching all messages, threads and images in threads. | ||||||
|  | - Searching for messages and threads. | ||||||
|  | - Creating groups, setting the group emoji, changing nicknames, creating polls, etc. | ||||||
|  | - Listening for, an reacting to messages and other events in real-time. | ||||||
|  | - Type hints, and it has a modern codebase (e.g. only Python 3.5 and upwards). | ||||||
|  | - ``async``/``await`` (COMING). | ||||||
|  |  | ||||||
|  | Essentially, everything you need to make an amazing Facebook bot! | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version Warning | ||||||
|  | --------------- | ||||||
|  | ``v2`` is currently being developed at the ``master`` branch and it's highly unstable. If you want to view the old ``v1``, go `here <https://github.com/carpedm20/fbchat/tree/v1>`__. | ||||||
|  |  | ||||||
|  | Additionally, you can view the project's progress `here <https://github.com/carpedm20/fbchat/projects/2>`__. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Caveats | ||||||
|  | ------- | ||||||
|  |  | ||||||
|  | ``fbchat`` works by imitating what the browser does, and thereby tricking Facebook into thinking it's accessing the website normally. | ||||||
|  |  | ||||||
|  | However, there's a catch! **Using this library may not comply with Facebook's Terms Of Service!**, so be responsible Facebook citizens! We are not responsible if your account gets banned! | ||||||
|  |  | ||||||
|  | Additionally, **the APIs the library is calling is undocumented!** In theory, this means that your code could break tomorrow, without the slightest warning! | ||||||
|  | If this happens to you, please report it, so that we can fix it as soon as possible! | ||||||
|  |  | ||||||
|  | .. inclusion-marker-intro-end | ||||||
|  | .. This message doesn't make sense in the docs at Read The Docs, so we exclude it | ||||||
|  |  | ||||||
|  | With that out of the way, you may go to `Read The Docs <https://fbchat.readthedocs.io/>`__ to see the full documentation! | ||||||
|  |  | ||||||
|  | .. inclusion-marker-installation-start | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Installation | ||||||
|  | ------------ | ||||||
|  |  | ||||||
|  | .. code-block:: | ||||||
|  |  | ||||||
|     $ pip install fbchat |     $ pip install fbchat | ||||||
|  |  | ||||||
| © Copyright 2015 - 2017 by Taehoon Kim / `@carpedm20 <http://carpedm20.github.io/about/>`__ | If you don't have `pip <https://pip.pypa.io/>`_, `this guide <http://docs.python-guide.org/en/latest/starting/installation/>`_ can guide you through the process. | ||||||
|  |  | ||||||
|  | You can also install directly from source, provided you have ``pip>=19.0``: | ||||||
|  |  | ||||||
|  | .. code-block:: | ||||||
|  |  | ||||||
|  |     $ pip install git+https://github.com/carpedm20/fbchat.git | ||||||
|  |  | ||||||
|  | .. inclusion-marker-installation-end | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Example Usage | ||||||
|  | ------------- | ||||||
|  |  | ||||||
|  | .. code-block:: | ||||||
|  |  | ||||||
|  |     import getpass | ||||||
|  |     import fbchat | ||||||
|  |     session = fbchat.Session.login("<email/phone number>", getpass.getpass()) | ||||||
|  |     user = fbchat.User(session=session, id=session.user_id) | ||||||
|  |     user.send_text("Test message!") | ||||||
|  |  | ||||||
|  | More examples are available `here <https://github.com/carpedm20/fbchat/tree/master/examples>`__. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Maintainer | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | - Mads Marquart / `@madsmtm <https://github.com/madsmtm>`__ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Acknowledgements | ||||||
|  | ---------------- | ||||||
|  |  | ||||||
|  | This project was originally inspired by `facebook-chat-api <https://github.com/Schmavery/facebook-chat-api>`__. | ||||||
|   | |||||||
| @@ -3,8 +3,7 @@ | |||||||
|  |  | ||||||
| # You can set these variables from the command line. | # You can set these variables from the command line. | ||||||
| SPHINXOPTS    = | SPHINXOPTS    = | ||||||
| SPHINXBUILD   = python3.6 -msphinx | SPHINXBUILD   = sphinx-build | ||||||
| SPHINXPROJ    = fbchat |  | ||||||
| SOURCEDIR     = . | SOURCEDIR     = . | ||||||
| BUILDDIR      = _build | BUILDDIR      = _build | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								docs/_static/find-group-id.png
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/_static/find-group-id.png
									
									
									
									
										vendored
									
									
								
							
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 59 KiB | 
							
								
								
									
										1
									
								
								docs/_static/license.svg
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										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
									
								
								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 | 
							
								
								
									
										44
									
								
								docs/api.rst
									
									
									
									
									
								
							
							
						
						
									
										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: |  | ||||||
							
								
								
									
										13
									
								
								docs/api/attachments.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								docs/api/attachments.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | Attachments | ||||||
|  | =========== | ||||||
|  |  | ||||||
|  | .. autoclass:: Attachment() | ||||||
|  | .. autoclass:: ShareAttachment() | ||||||
|  | .. autoclass:: Sticker() | ||||||
|  | .. autoclass:: LocationAttachment() | ||||||
|  | .. autoclass:: LiveLocationAttachment() | ||||||
|  | .. autoclass:: FileAttachment() | ||||||
|  | .. autoclass:: AudioAttachment() | ||||||
|  | .. autoclass:: ImageAttachment() | ||||||
|  | .. autoclass:: VideoAttachment() | ||||||
|  | .. autoclass:: ImageAttachment() | ||||||
							
								
								
									
										4
									
								
								docs/api/client.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								docs/api/client.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | Client | ||||||
|  | ====== | ||||||
|  |  | ||||||
|  | .. autoclass:: Client | ||||||
							
								
								
									
										4
									
								
								docs/api/events.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								docs/api/events.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | Events | ||||||
|  | ====== | ||||||
|  |  | ||||||
|  | .. autoclass:: Listener | ||||||
							
								
								
									
										11
									
								
								docs/api/exceptions.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								docs/api/exceptions.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | Exceptions | ||||||
|  | ========== | ||||||
|  |  | ||||||
|  | .. autoexception:: FacebookError() | ||||||
|  | .. autoexception:: HTTPError() | ||||||
|  | .. autoexception:: ParseError() | ||||||
|  | .. autoexception:: NotLoggedIn() | ||||||
|  | .. autoexception:: ExternalError() | ||||||
|  | .. autoexception:: GraphQLError() | ||||||
|  | .. autoexception:: InvalidParameters() | ||||||
|  | .. autoexception:: PleaseRefresh() | ||||||
							
								
								
									
										21
									
								
								docs/api/index.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								docs/api/index.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | .. module:: fbchat | ||||||
|  |  | ||||||
|  | .. Note: we're using () to hide the __init__ method where relevant | ||||||
|  |  | ||||||
|  | Full API | ||||||
|  | ======== | ||||||
|  |  | ||||||
|  | If you are looking for information on a specific function, class, or method, this part of the documentation is for you. | ||||||
|  |  | ||||||
|  | .. toctree:: | ||||||
|  |     :maxdepth: 1 | ||||||
|  |  | ||||||
|  |     session | ||||||
|  |     client | ||||||
|  |     threads | ||||||
|  |     thread_data | ||||||
|  |     messages | ||||||
|  |     exceptions | ||||||
|  |     attachments | ||||||
|  |     events | ||||||
|  |     misc | ||||||
							
								
								
									
										8
									
								
								docs/api/messages.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								docs/api/messages.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | Messages | ||||||
|  | ======== | ||||||
|  |  | ||||||
|  | .. autoclass:: Message | ||||||
|  | .. autoclass:: Mention | ||||||
|  | .. autoclass:: EmojiSize(Enum) | ||||||
|  |     :undoc-members: | ||||||
|  | .. autoclass:: MessageData() | ||||||
							
								
								
									
										20
									
								
								docs/api/misc.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								docs/api/misc.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | Miscellaneous | ||||||
|  | ============= | ||||||
|  |  | ||||||
|  | .. autoclass:: ThreadLocation(Enum) | ||||||
|  |     :undoc-members: | ||||||
|  | .. autoclass:: ActiveStatus() | ||||||
|  |  | ||||||
|  | .. autoclass:: QuickReply | ||||||
|  | .. autoclass:: QuickReplyText | ||||||
|  | .. autoclass:: QuickReplyLocation | ||||||
|  | .. autoclass:: QuickReplyPhoneNumber | ||||||
|  | .. autoclass:: QuickReplyEmail | ||||||
|  |  | ||||||
|  | .. autoclass:: Poll | ||||||
|  | .. autoclass:: PollOption | ||||||
|  |  | ||||||
|  | .. autoclass:: Plan | ||||||
|  | .. autoclass:: PlanData() | ||||||
|  | .. autoclass:: GuestStatus(Enum) | ||||||
|  |     :undoc-members: | ||||||
							
								
								
									
										4
									
								
								docs/api/session.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								docs/api/session.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | Session | ||||||
|  | ======= | ||||||
|  |  | ||||||
|  | .. autoclass:: Session() | ||||||
							
								
								
									
										6
									
								
								docs/api/thread_data.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/thread_data.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | Thread Data | ||||||
|  | =========== | ||||||
|  |  | ||||||
|  | .. autoclass:: PageData() | ||||||
|  | .. autoclass:: UserData() | ||||||
|  | .. autoclass:: GroupData() | ||||||
							
								
								
									
										8
									
								
								docs/api/threads.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								docs/api/threads.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | Threads | ||||||
|  | ======= | ||||||
|  |  | ||||||
|  | .. autoclass:: ThreadABC() | ||||||
|  | .. autoclass:: Thread | ||||||
|  | .. autoclass:: Page | ||||||
|  | .. autoclass:: User | ||||||
|  | .. autoclass:: Group | ||||||
							
								
								
									
										245
									
								
								docs/conf.py
									
									
									
									
									
								
							
							
						
						
									
										245
									
								
								docs/conf.py
									
									
									
									
									
								
							| @@ -1,191 +1,194 @@ | |||||||
| #!/usr/bin/env python3 | # Configuration file for the Sphinx documentation builder. | ||||||
| # -*- coding: utf-8 -*- |  | ||||||
| # | # | ||||||
| # fbchat documentation build configuration file, created by | # This file does only contain a selection of the most common options. For a | ||||||
| # sphinx-quickstart on Thu May 25 15:43:01 2017. | # full list see the documentation: | ||||||
| # | # http://www.sphinx-doc.org/en/master/config | ||||||
| # 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, | # -- Path setup -------------------------------------------------------------- | ||||||
| # 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 os | ||||||
| import sys | import sys | ||||||
| sys.path.insert(0, os.path.abspath('..')) |  | ||||||
|  | sys.path.insert(0, os.path.abspath("..")) | ||||||
|  |  | ||||||
|  | os.environ["_FBCHAT_DISABLE_FIX_MODULE_METADATA"] = "1" | ||||||
|  |  | ||||||
| import fbchat | import fbchat | ||||||
| import tests |  | ||||||
| from fbchat import __copyright__, __author__, __version__, __description__ | del os.environ["_FBCHAT_DISABLE_FIX_MODULE_METADATA"] | ||||||
|  |  | ||||||
|  | # -- Project information ----------------------------------------------------- | ||||||
|  |  | ||||||
|  | project = fbchat.__name__ | ||||||
|  | copyright = "Copyright 2015 - 2018 by Taehoon Kim and 2018 - 2020 by Mads Marquart" | ||||||
|  | author = "Taehoon Kim; Moreels Pieter-Jan; Mads Marquart" | ||||||
|  | description = fbchat.__doc__.split("\n")[0] | ||||||
|  |  | ||||||
|  | # The short X.Y version | ||||||
|  | version = fbchat.__version__ | ||||||
|  | # The full version, including alpha/beta/rc tags | ||||||
|  | release = fbchat.__version__ | ||||||
|  |  | ||||||
|  |  | ||||||
| # -- General configuration ------------------------------------------------ | # -- General configuration --------------------------------------------------- | ||||||
|  |  | ||||||
| # If your documentation needs a minimal Sphinx version, state it here. | # If your documentation needs a minimal Sphinx version, state it here. | ||||||
| # | # | ||||||
| # needs_sphinx = '1.0' | needs_sphinx = "2.0" | ||||||
|  |  | ||||||
| # Add any Sphinx extension module names here, as strings. They can be | # Add any Sphinx extension module names here, as strings. They can be | ||||||
| # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom | ||||||
| # ones. | # ones. | ||||||
| extensions = [ | extensions = [ | ||||||
|     'sphinx.ext.autodoc', |     "sphinx.ext.autodoc", | ||||||
|     'sphinx.ext.intersphinx', |     "sphinx.ext.intersphinx", | ||||||
|     'sphinx.ext.todo', |     "sphinx.ext.viewcode", | ||||||
|     'sphinx.ext.viewcode' |     "sphinx.ext.napoleon", | ||||||
|  |     "sphinxcontrib.spelling", | ||||||
|  |     "sphinx_autodoc_typehints", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| # Add any paths that contain templates here, relative to this directory. | # Add any paths that contain templates here, relative to this directory. | ||||||
| templates_path = ['_templates'] | 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. | # The master toctree document. | ||||||
| master_doc = 'index' | 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 | # List of patterns, relative to source directory, that match files and | ||||||
| # directories to ignore when looking for source files. | # directories to ignore when looking for source files. | ||||||
| # This patterns also effect to html_static_path and html_extra_path | # This pattern also affects html_static_path and html_extra_path. | ||||||
| exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] | ||||||
|  |  | ||||||
| # The name of the Pygments (syntax highlighting) style to use. | rst_prolog = ".. currentmodule:: " + project | ||||||
| pygments_style = 'sphinx' |  | ||||||
|  |  | ||||||
| # If true, `todo` and `todoList` produce output, else they produce nothing. | # The reST default role (used for this markup: `text`) to use for all | ||||||
| todo_include_todos = True | # documents. | ||||||
|  | # | ||||||
|  | default_role = "any" | ||||||
|  |  | ||||||
|  | # Make the reference parsing more strict | ||||||
|  | # | ||||||
|  | nitpicky = True | ||||||
|  |  | ||||||
|  | # Prefer strict Python highlighting | ||||||
|  | # | ||||||
|  | highlight_language = "python3" | ||||||
|  |  | ||||||
|  | # If true, '()' will be appended to :func: etc. cross-reference text. | ||||||
|  | # | ||||||
|  | add_function_parentheses = False | ||||||
|  |  | ||||||
|  |  | ||||||
| # -- Options for HTML output ---------------------------------------------- | # -- Options for HTML output ------------------------------------------------- | ||||||
|  |  | ||||||
| # The theme to use for HTML and HTML Help pages.  See the documentation for | # The theme to use for HTML and HTML Help pages.  See the documentation for | ||||||
| # a list of builtin themes. | # a list of builtin themes. | ||||||
| # | # | ||||||
|  | html_theme = "alabaster" | ||||||
| html_theme = 'alabaster' |  | ||||||
|  |  | ||||||
| # Theme options are theme-specific and customize the look and feel of a theme | # 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 | # further.  For a list of options available for each theme, see the | ||||||
| # documentation. | # documentation. | ||||||
| # | # | ||||||
| # html_theme_options = {} | html_theme_options = { | ||||||
|  |     "show_powered_by": False, | ||||||
|  |     "github_user": "carpedm20", | ||||||
|  |     "github_repo": project, | ||||||
|  |     "github_banner": True, | ||||||
|  |     "show_related": False, | ||||||
|  | } | ||||||
|  |  | ||||||
| # Add any paths that contain custom static files (such as style sheets) here, | # Custom sidebar templates, must be a dictionary that maps document names | ||||||
| # relative to this directory. They are copied after the builtin static files, | # to template names. | ||||||
| # so a file named "default.css" will overwrite the builtin "default.css". | # | ||||||
| html_static_path = ['_static'] | # The default sidebars (for documents that don't match any pattern) are | ||||||
|  | # defined by theme itself.  Builtin themes are using these templates by | ||||||
|  | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', | ||||||
|  | # 'searchbox.html']``. | ||||||
|  | # | ||||||
|  | html_sidebars = {"**": ["sidebar.html", "searchbox.html"]} | ||||||
|  |  | ||||||
|  | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. | ||||||
|  | # | ||||||
|  | html_show_sphinx = False | ||||||
|  |  | ||||||
|  | # If true, links to the reST sources are added to the pages. | ||||||
|  | # | ||||||
|  | html_show_sourcelink = False | ||||||
|  |  | ||||||
|  | # A shorter title for the navigation bar. Default is the same as html_title. | ||||||
|  | # | ||||||
|  | html_short_title = description | ||||||
|  |  | ||||||
|  |  | ||||||
| # -- Options for HTMLHelp output ------------------------------------------ | # -- Options for HTMLHelp output --------------------------------------------- | ||||||
|  |  | ||||||
| # Output file base name for HTML help builder. | # Output file base name for HTML help builder. | ||||||
| htmlhelp_basename = project + 'doc' | htmlhelp_basename = project + "doc" | ||||||
|  |  | ||||||
|  |  | ||||||
| # -- Options for LaTeX output --------------------------------------------- | # -- 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 | # Grouping the document tree into LaTeX files. List of tuples | ||||||
| # (source start file, target name, title, | # (source start file, target name, title, | ||||||
| #  author, documentclass [howto, manual, or own class]). | #  author, documentclass [howto, manual, or own class]). | ||||||
| latex_documents = [ | latex_documents = [(master_doc, project + ".tex", project, author, "manual")] | ||||||
|     (master_doc, project + '.tex', title, |  | ||||||
|      author, 'manual'), |  | ||||||
| ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # -- Options for manual page output --------------------------------------- | # -- Options for manual page output ------------------------------------------ | ||||||
|  |  | ||||||
| # One entry per manual page. List of tuples | # One entry per manual page. List of tuples | ||||||
| # (source start file, name, description, authors, manual section). | # (source start file, name, description, authors, manual section). | ||||||
| man_pages = [ | man_pages = [(master_doc, project, project, [x.strip() for x in author.split(";")], 1)] | ||||||
|     (master_doc, project, title, |  | ||||||
|      [author], 1) |  | ||||||
| ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # -- Options for Texinfo output ------------------------------------------- | # -- Options for Texinfo output ---------------------------------------------- | ||||||
|  |  | ||||||
| # Grouping the document tree into Texinfo files. List of tuples | # Grouping the document tree into Texinfo files. List of tuples | ||||||
| # (source start file, target name, title, author, | # (source start file, target name, title, author, | ||||||
| #  dir menu entry, description, category) | #  dir menu entry, description, category) | ||||||
| texinfo_documents = [ | texinfo_documents = [ | ||||||
|     (master_doc, project, title, |     (master_doc, project, project, author, project, description, "Miscellaneous",) | ||||||
|      author, project, description, |  | ||||||
|      'Miscellaneous'), |  | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # -- Options for Epub output ------------------------------------------------- | ||||||
|  |  | ||||||
|  | # A list of files that should not be packed into the epub file. | ||||||
|  | epub_exclude_files = ["search.html"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # -- Extension configuration ------------------------------------------------- | ||||||
|  |  | ||||||
|  | # -- Options for autodoc extension --------------------------------------- | ||||||
|  |  | ||||||
|  | autoclass_content = "class" | ||||||
|  | autodoc_member_order = "bysource" | ||||||
|  | autodoc_default_options = {"members": True} | ||||||
|  |  | ||||||
|  | # -- Options for intersphinx extension --------------------------------------- | ||||||
|  |  | ||||||
| # Example configuration for intersphinx: refer to the Python standard library. | # Example configuration for intersphinx: refer to the Python standard library. | ||||||
| intersphinx_mapping = {'https://docs.python.org/3/': None} | intersphinx_mapping = {"https://docs.python.org/": None} | ||||||
|  |  | ||||||
|  | # -- Options for napoleon extension ---------------------------------------------- | ||||||
|  |  | ||||||
|  | # Use Google style docstrings | ||||||
|  | napoleon_google_docstring = True | ||||||
|  | napoleon_numpy_docstring = False | ||||||
|  |  | ||||||
| add_function_parentheses = False | # napoleon_use_admonition_for_examples = False | ||||||
|  | # napoleon_use_admonition_for_notes = False | ||||||
|  | # napoleon_use_admonition_for_references = False | ||||||
|  |  | ||||||
| html_theme_options = { | # -- Options for spelling extension ---------------------------------------------- | ||||||
|     'show_powered_by': False, |  | ||||||
|     'github_user': 'carpedm20', |  | ||||||
|     'github_repo': project, |  | ||||||
|     'github_banner': True, |  | ||||||
|     'show_related': False |  | ||||||
| } |  | ||||||
|  |  | ||||||
| html_sidebars = { | spelling_word_list_filename = [ | ||||||
|     '**': ['sidebar.html', 'searchbox.html'] |     "spelling/names.txt", | ||||||
| } |     "spelling/technical.txt", | ||||||
|  |     "spelling/fixes.txt", | ||||||
| html_show_sphinx = False | ] | ||||||
| html_show_sourcelink = False | spelling_ignore_wiki_words = False | ||||||
| autoclass_content = 'init' | # spelling_ignore_acronyms = False | ||||||
| html_short_title = description | spelling_ignore_python_builtins = False | ||||||
|  | spelling_ignore_importable_modules = False | ||||||
|   | |||||||
| @@ -1,16 +1,15 @@ | |||||||
| .. highlight:: python |  | ||||||
| .. _examples: | .. _examples: | ||||||
|  |  | ||||||
| Examples | Examples | ||||||
| ======== | ======== | ||||||
|  |  | ||||||
| These are a few examples on how to use `fbchat`. Remember to swap out `<email>` and `<password>` for your email and password | These are a few examples on how to use ``fbchat``. Remember to swap out ``<email>`` and ``<password>`` for your email and password | ||||||
|  |  | ||||||
|  |  | ||||||
| Basic example | Basic example | ||||||
| ------------- | ------------- | ||||||
|  |  | ||||||
| This will show basic usage of `fbchat` | This will show basic usage of ``fbchat`` | ||||||
|  |  | ||||||
| .. literalinclude:: ../examples/basic_usage.py | .. literalinclude:: ../examples/basic_usage.py | ||||||
|  |  | ||||||
| @@ -18,7 +17,7 @@ This will show basic usage of `fbchat` | |||||||
| Interacting with Threads | Interacting with Threads | ||||||
| ------------------------ | ------------------------ | ||||||
|  |  | ||||||
| This will interract with the thread in every way `fbchat` supports | This will interact with the thread in every way ``fbchat`` supports | ||||||
|  |  | ||||||
| .. literalinclude:: ../examples/interract.py | .. literalinclude:: ../examples/interract.py | ||||||
|  |  | ||||||
| @@ -31,8 +30,8 @@ This will show the different ways of fetching information about users and thread | |||||||
| .. literalinclude:: ../examples/fetch.py | .. literalinclude:: ../examples/fetch.py | ||||||
|  |  | ||||||
|  |  | ||||||
| Echobot | ``Echobot`` | ||||||
| ------- | ----------- | ||||||
|  |  | ||||||
| This will reply to any message with the same message | This will reply to any message with the same message | ||||||
|  |  | ||||||
| @@ -42,7 +41,7 @@ This will reply to any message with the same message | |||||||
| Remove Bot | Remove Bot | ||||||
| ---------- | ---------- | ||||||
|  |  | ||||||
| This will remove a user from a group if they write the message `Remove me!` | This will remove a user from a group if they write the message ``Remove me!`` | ||||||
|  |  | ||||||
| .. literalinclude:: ../examples/removebot.py | .. literalinclude:: ../examples/removebot.py | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										43
									
								
								docs/faq.rst
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								docs/faq.rst
									
									
									
									
									
								
							| @@ -1,44 +1,23 @@ | |||||||
| .. highlight:: python | Frequently Asked Questions | ||||||
| .. module:: fbchat | ========================== | ||||||
| .. _faq: |  | ||||||
|  |  | ||||||
| FAQ | The new version broke my application | ||||||
| === | ------------------------------------ | ||||||
|  |  | ||||||
| Version X broke my installation | ``fbchat`` follows `Scemantic Versioning <https://semver.org/>`__ quite rigorously! | ||||||
| ------------------------------- |  | ||||||
|  |  | ||||||
| We try to provide backwards compatability where possible, but since we're not part of Facebook, | That means that breaking changes can *only* occur in major versions (e.g. ``v1.9.6`` -> ``v2.0.0``). | ||||||
| most of the things may be broken at any point in time |  | ||||||
|  |  | ||||||
| Downgrade to an earlier version of fbchat, run this command | If you find that something breaks, and you didn't update to a new major version, then it is a bug, and we would be grateful if you reported it! | ||||||
|  |  | ||||||
|  | In case you're stuck with an old codebase, you can downgrade to a previous version of ``fbchat``, e.g. version ``1.9.6``: | ||||||
|  |  | ||||||
| .. code-block:: sh | .. code-block:: sh | ||||||
|  |  | ||||||
|     $ pip install fbchat==<X> |     $ pip install fbchat==1.9.6 | ||||||
|  |  | ||||||
| Where you replace ``<X>`` with the version you want to use |  | ||||||
|  |  | ||||||
|  |  | ||||||
| Will you be supporting creating posts/events/pages and so on? | 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 ;) | We won't be focusing on anything else than chat-related things. This library 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 +1,23 @@ | |||||||
| .. highlight:: python | .. highlight:: sh | ||||||
| .. module:: fbchat | .. See README.rst for explanation of these markers | ||||||
| .. 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 | .. include:: ../README.rst | ||||||
|    Some documentation is also partially copied from facebook-chat-api: https://github.com/Schmavery/facebook-chat-api |     :end-before: inclusion-marker-intro-end | ||||||
|  |  | ||||||
| fbchat: Facebook Chat (Messenger) for Python | With that said, let's get started! | ||||||
| ============================================ |  | ||||||
|  |  | ||||||
| Release v\ |version|. (:ref:`install`) | .. include:: ../README.rst | ||||||
|  |     :start-after: inclusion-marker-installation-start | ||||||
| .. generated with: https://img.shields.io/badge/license-BSD-blue.svg |     :end-before: inclusion-marker-installation-end | ||||||
|  |  | ||||||
| .. 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 | Documentation Overview | ||||||
| -------- | ---------------------- | ||||||
|  |  | ||||||
| .. toctree:: | .. toctree:: | ||||||
|     :maxdepth: 2 |     :maxdepth: 2 | ||||||
|  |  | ||||||
|     install |  | ||||||
|     intro |     intro | ||||||
|     examples |     examples | ||||||
|     testing |  | ||||||
|     api |  | ||||||
|     todo |  | ||||||
|     faq |     faq | ||||||
|  |     api/index | ||||||
|   | |||||||
| @@ -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 |  | ||||||
							
								
								
									
										222
									
								
								docs/intro.rst
									
									
									
									
									
								
							
							
						
						
									
										222
									
								
								docs/intro.rst
									
									
									
									
									
								
							| @@ -1,200 +1,152 @@ | |||||||
| .. highlight:: python |  | ||||||
| .. module:: fbchat |  | ||||||
| .. _intro: |  | ||||||
|  |  | ||||||
| Introduction | Introduction | ||||||
| ============ | ============ | ||||||
|  |  | ||||||
| `fbchat` uses your email and password to communicate with the Facebook server. | Welcome, this page will guide you through the basic concepts of using ``fbchat``. | ||||||
| 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 |  | ||||||
|  |  | ||||||
|  | The hardest, and most error prone part is logging in, and managing your login session, so that is what we will look at first. | ||||||
|  |  | ||||||
| .. _intro_logging_in: |  | ||||||
|  |  | ||||||
| Logging In | Logging In | ||||||
| ---------- | ---------- | ||||||
|  |  | ||||||
| Simply create an instance of :class:`Client`. If you have two factor authentication enabled, type the code in the terminal prompt | Everything in ``fbchat`` starts with getting an instance of `Session`. Currently there are two ways of doing that, `Session.login` and `Session.from_cookies`. | ||||||
| (If you want to supply the code in another fasion, overwrite :func:`Client.on2FACode`):: |  | ||||||
|  |  | ||||||
|     from fbchat import Client | The follow example will prompt you for you password, and use it to login:: | ||||||
|     from fbchat.models import * |  | ||||||
|     client = Client('<email>', '<password>') |  | ||||||
|  |  | ||||||
| Replace ``<email>`` and ``<password>`` with your email and password respectively |     import getpass | ||||||
|  |     import fbchat | ||||||
|  |     session = fbchat.Session.login("<email/phone number>", getpass.getpass()) | ||||||
|  |     # If your account requires a two factor authentication code: | ||||||
|  |     session = fbchat.Session.login( | ||||||
|  |         "<your email/phone number>", | ||||||
|  |         getpass.getpass(), | ||||||
|  |         lambda: getpass.getpass("2FA code"), | ||||||
|  |     ) | ||||||
|  |  | ||||||
| .. note:: | However, **this is not something you should do often!** Logging in/out all the time *will* get your Facebook account locked! | ||||||
|     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`) | Instead, you should start by using `Session.login`, and then store the cookies with `Session.get_cookies`, so that they can be used instead the next time your application starts. | ||||||
|  |  | ||||||
| Throughout your code, if you want to check whether you are still logged in, use :func:`Client.isLoggedIn`. | Usability-wise, this is also better, since you won't have to re-type your password every time you want to login. | ||||||
| An example would be to login again if you've been logged out, using :func:`Client.login`:: |  | ||||||
|  |  | ||||||
|     if not client.isLoggedIn(): | The following, quite lengthy, yet very import example, illustrates a way to do this: | ||||||
|         client.login('<email>', '<password>') |  | ||||||
|  |  | ||||||
| When you're done using the client, and want to securely logout, use :func:`Client.logout`:: | .. literalinclude:: ../examples/session_handling.py | ||||||
|  |  | ||||||
|     client.logout() | Assuming you have successfully completed the above, congratulations! Using ``fbchat`` should be mostly trouble free from now on! | ||||||
|  |  | ||||||
|  |  | ||||||
| .. _intro_threads: | Understanding Thread Ids | ||||||
|  | ------------------------ | ||||||
|  |  | ||||||
| Threads | At the core of any thread is its unique identifier, its ID. | ||||||
| ------- |  | ||||||
|  |  | ||||||
| A thread can refer to two things: A Messenger group chat or a single Facebook user | A thread basically just means "something I can chat with", but more precisely, it can refer to a few things: | ||||||
|  | - A Messenger group thread (`Group`) | ||||||
|  | - The conversation between you and a single Facebook user (`User`) | ||||||
|  | - The conversation between you and a Facebook Page (`Page`) | ||||||
|  |  | ||||||
| :class:`models.ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``. | You can get your own user ID from `Session.user` with ``session.user.id``. | ||||||
| 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`, | Getting the ID of a specific group thread is fairly trivial, you only need to login to `<https://www.messenger.com/>`_, click on the group you want to find the ID of, and then read the id from the address bar. | ||||||
| and searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching` | The URL will look something like this: ``https://www.messenger.com/t/1234567890``, where ``1234567890`` would be the ID of the group. | ||||||
|  |  | ||||||
| You can get your own user ID by using :any:`Client.uid` | The same method can be applied to some user accounts, though if they have set a custom URL, then you will have to use a different method. | ||||||
|  |  | ||||||
| Getting the ID of a group chat is fairly trivial otherwise, since you only need to navigate to `<https://www.facebook.com/messages/>`_, | An image to illustrate the process is shown below: | ||||||
| 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 | .. image:: /_static/find-group-id.png | ||||||
|     :alt: An image illustrating how to find the ID of a group |     :alt: An image illustrating how to find the ID of a group | ||||||
|  |  | ||||||
| The same method can be applied to some user accounts, though if they've set a custom URL, then you'll just see that URL instead | Once you have an ID, you can use it to create a `Group` or a `User` instance, which will allow you to do all sorts of things. To do this, you need a valid, logged in session:: | ||||||
|  |  | ||||||
| Here's an snippet showing the usage of thread IDs and thread types, where ``<user id>`` and ``<group id>`` |     group = fbchat.Group(session=session, id="<The id you found>") | ||||||
| corresponds to the ID of a single user, and the ID of a group respectively:: |     # Or for user threads | ||||||
|  |     user = fbchat.User(session=session, id="<The id you found>") | ||||||
|  |  | ||||||
|     client.sendMessage('<message>', thread_id='<user id>', thread_type=ThreadType.USER) | Just like threads, every message, poll, plan, attachment, action etc. you send or do on Facebook has a unique ID. | ||||||
|     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:: | Below is an example of using such a message ID to get a `Message` instance:: | ||||||
|  |  | ||||||
|     client.changeThreadColor(ThreadColor.BILOBA_FLOWER, thread_id='<user id>') |     # Provide the thread the message was created in, and it's ID | ||||||
|     client.changeThreadColor(ThreadColor.MESSENGER_BLUE, thread_id='<group id>') |     message = fbchat.Message(thread=user, id="<The message id>") | ||||||
|  |  | ||||||
|  |  | ||||||
| .. _intro_message_ids: |  | ||||||
|  |  | ||||||
| Message IDs |  | ||||||
| ----------- |  | ||||||
|  |  | ||||||
| Every message you send on Facebook has a unique ID, and every action you do in a thread, |  | ||||||
| like changing a nickname or adding a person, has a unique ID too. |  | ||||||
|  |  | ||||||
| Some of `fbchat`'s functions require these ID's, like :func:`Client.reactToMessage`, |  | ||||||
| and some of then provide this ID, like :func:`Client.sendMessage`. |  | ||||||
| This snippet shows how to send a message, and then use the returned ID to react to that message with a 😍 emoji:: |  | ||||||
|  |  | ||||||
|     message_id = client.sendMessage('message', thread_id=thread_id, thread_type=thread_type) |  | ||||||
|     client.reactToMessage(message_id, MessageReaction.LOVE) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| .. _intro_interacting: |  | ||||||
|  |  | ||||||
| Interacting with Threads |  | ||||||
| ------------------------ |  | ||||||
|  |  | ||||||
| `fbchat` provides multiple functions for interacting with threads |  | ||||||
|  |  | ||||||
| Most functionality works on all threads, though some things, |  | ||||||
| like adding users to and removing users from a group chat, logically only works on group chats |  | ||||||
|  |  | ||||||
| The simplest way of using `fbchat` is to send a message. |  | ||||||
| The following snippet will, as you've probably already figured out, send the message `test message` to your account:: |  | ||||||
|  |  | ||||||
|     message_id = client.sendMessage('test message', thread_id=client.uid, thread_type=ThreadType.USER) |  | ||||||
|  |  | ||||||
| You can see a full example showing all the possible thread interactions with `fbchat` by going to :ref:`examples` |  | ||||||
|  |  | ||||||
|  |  | ||||||
| .. _intro_fetching: |  | ||||||
|  |  | ||||||
| Fetching Information | Fetching Information | ||||||
| -------------------- | -------------------- | ||||||
|  |  | ||||||
| You can use `fbchat` to fetch basic information like user names, profile pictures, thread names and user IDs | Managing these ids yourself quickly becomes very cumbersome! Luckily, there are other, easier ways of getting `Group`/`User` instances. | ||||||
|  |  | ||||||
| You can retrieve a user's ID with :func:`Client.searchForUsers`. | You would start by creating a `Client` instance, which is basically just a helper on top of `Session`, that will allow you to do various things:: | ||||||
| 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>') |     client = fbchat.Client(session=session) | ||||||
|     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 | Now, you could search for threads using `Client.search_for_threads`, or fetch a list of them using `Client.fetch_threads`:: | ||||||
|  |  | ||||||
| You can see a full example showing all the possible ways to fetch information with `fbchat` by going to :ref:`examples` |     # Fetch the 5 most likely search results | ||||||
|  |     # Uses Facebook's search functions, you don't have to specify the whole name, first names will usually be enough | ||||||
|  |     threads = list(client.search_for_threads("<name of the thread to search for>", limit=5)) | ||||||
|  |     # Fetch the 5 most recent threads in your account | ||||||
|  |     threads = list(client.fetch_threads(limit=5)) | ||||||
|  |  | ||||||
|  | Note the `list` statements; this is because the methods actually return `generators <https://wiki.python.org/moin/Generators>`__. If you don't know what that means, don't worry, it is just something you can use to make your code faster later. | ||||||
|  |  | ||||||
|  | The examples above will actually fetch `UserData`/`GroupData`, which are subclasses of `User`/`Group`. These model have extra properties, so you could for example print the names and ids of the fetched threads like this:: | ||||||
|  |  | ||||||
|  |     for thread in threads: | ||||||
|  |         print(f"{thread.id}: {thread.name}") | ||||||
|  |  | ||||||
|  | Once you have a thread, you can use that to fetch the messages therein:: | ||||||
|  |  | ||||||
|  |     for message in thread.fetch_messages(limit=20): | ||||||
|  |         print(message.text) | ||||||
|  |  | ||||||
|  |  | ||||||
| .. _intro_sessions: | Interacting with Threads | ||||||
|  | ------------------------ | ||||||
|  |  | ||||||
| Sessions | Once you have a `User`/`Group` instance, you can do things on them as described in `ThreadABC`, since they are subclasses of that. | ||||||
| -------- |  | ||||||
|  |  | ||||||
| `fbchat` provides functions to retrieve and set the session cookies. | Some functionality, like adding users to a `Group`, or blocking a `User`, logically only works the relevant threads, so see the full API documentation for that. | ||||||
| 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() | With that out of the way, let's see some examples! | ||||||
|  |  | ||||||
| Then you can use :func:`Client.setSession`:: | The simplest way of interacting with a thread is by sending a message:: | ||||||
|  |  | ||||||
|     client.setSession(session_cookies) |     # Send a message to the user | ||||||
|  |     message = user.send_text("test message") | ||||||
|  |  | ||||||
| Or you can set the ``session_cookies`` on your initial login. | There are many types of messages you can send, see the full API documentation for more. | ||||||
| (If the session cookies are invalid, your email and password will be used to login instead):: |  | ||||||
|  |  | ||||||
|     client = Client('<email>', '<password>', session_cookies=session_cookies) | Notice how we held on to the sent message? The return type i a `Message` instance, so you can interact with it afterwards:: | ||||||
|  |  | ||||||
| .. warning:: |     # React to the message with the 😍 emoji | ||||||
|     You session cookies can be just as valueable as you password, so store them with equal care |     message.react("😍") | ||||||
|  |  | ||||||
|  | Besides sending messages, you can also interact with threads in other ways. An example is to change the thread color:: | ||||||
|  |  | ||||||
|  |     # Will change the thread color to the default blue | ||||||
|  |     thread.set_color("#0084ff") | ||||||
|  |  | ||||||
| .. _intro_events: |  | ||||||
|  |  | ||||||
| Listening & Events | Listening & Events | ||||||
| ------------------ | ------------------ | ||||||
|  |  | ||||||
| To use the listening functions `fbchat` offers (like :func:`Client.listen`), | Now, we are finally at the point we have all been waiting for: Creating an automatic Facebook bot! | ||||||
| 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:: | To get started, you create the functions you want to call on certain events:: | ||||||
|     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:: |     def my_function(event: fbchat.MessageEvent): | ||||||
|  |         print(f"Message from {event.author.id}: {event.message.text}") | ||||||
|  |  | ||||||
|     class CustomClient(Client): | Then you create a `fbchat.Listener` object:: | ||||||
|         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>') |     listener = fbchat.Listener(session=session, chat_on=False, foreground=False) | ||||||
|  |  | ||||||
| **Notice:** The following snippet is as equally valid as the previous one:: | Which you can then use to receive events, and send them to your functions:: | ||||||
|  |  | ||||||
|     class CustomClient(Client): |     for event in listener.listen(): | ||||||
|         def onMessage(self, message, author_id, thread_id, thread_type, **kwargs): |         if isinstance(event, fbchat.MessageEvent): | ||||||
|             # Do something with the message here |             my_function(event) | ||||||
|             pass |  | ||||||
|  |  | ||||||
|     client = CustomClient('<email>', '<password>') | View the :ref:`examples` to see some more examples illustrating the event system. | ||||||
|  |  | ||||||
| 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 |  | ||||||
|   | |||||||
| @@ -5,21 +5,20 @@ pushd %~dp0 | |||||||
| REM Command file for Sphinx documentation | REM Command file for Sphinx documentation | ||||||
|  |  | ||||||
| if "%SPHINXBUILD%" == "" ( | if "%SPHINXBUILD%" == "" ( | ||||||
| 	set SPHINXBUILD=python -msphinx | 	set SPHINXBUILD=sphinx-build | ||||||
| ) | ) | ||||||
| set SOURCEDIR=. | set SOURCEDIR=. | ||||||
| set BUILDDIR=_build | set BUILDDIR=_build | ||||||
| set SPHINXPROJ=fbchat |  | ||||||
|  |  | ||||||
| if "%1" == "" goto help | if "%1" == "" goto help | ||||||
|  |  | ||||||
| %SPHINXBUILD% >NUL 2>NUL | %SPHINXBUILD% >NUL 2>NUL | ||||||
| if errorlevel 9009 ( | if errorlevel 9009 ( | ||||||
| 	echo. | 	echo. | ||||||
| 	echo.The Sphinx module was not found. Make sure you have Sphinx installed, | 	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx | ||||||
| 	echo.then set the SPHINXBUILD environment variable to point to the full | 	echo.installed, then set the SPHINXBUILD environment variable to point | ||||||
| 	echo.path of the 'sphinx-build' executable. Alternatively you may add the | 	echo.to the full path of the 'sphinx-build' executable. Alternatively you | ||||||
| 	echo.Sphinx directory to PATH. | 	echo.may add the Sphinx directory to PATH. | ||||||
| 	echo. | 	echo. | ||||||
| 	echo.If you don't have Sphinx installed, grab it from | 	echo.If you don't have Sphinx installed, grab it from | ||||||
| 	echo.http://sphinx-doc.org/ | 	echo.http://sphinx-doc.org/ | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								docs/spelling/fixes.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								docs/spelling/fixes.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | premade | ||||||
|  | todo | ||||||
|  | emoji | ||||||
							
								
								
									
										3
									
								
								docs/spelling/names.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								docs/spelling/names.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | Facebook | ||||||
|  | GraphQL | ||||||
|  | GitHub | ||||||
							
								
								
									
										17
									
								
								docs/spelling/technical.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								docs/spelling/technical.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | iterables | ||||||
|  | iterable | ||||||
|  | mimetype | ||||||
|  | timestamp | ||||||
|  | metadata | ||||||
|  | spam | ||||||
|  | spammy | ||||||
|  | admin | ||||||
|  | admins | ||||||
|  | unsend | ||||||
|  | unsends | ||||||
|  | unmute | ||||||
|  | spritemap | ||||||
|  | online | ||||||
|  | inbox | ||||||
|  | subclassing | ||||||
|  | codebase | ||||||
| @@ -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 | # Log the user in | ||||||
| from fbchat.models import * | 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) | # Log the user out | ||||||
|  | session.logout() | ||||||
| client.logout() |  | ||||||
|   | |||||||
| @@ -1,18 +1,11 @@ | |||||||
| # -*- coding: UTF-8 -*- | import fbchat | ||||||
|  |  | ||||||
| from fbchat import log, Client | session = fbchat.Session.login("<email>", "<password>") | ||||||
|  | listener = fbchat.Listener(session=session, chat_on=False, foreground=False) | ||||||
| # 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)) |  | ||||||
|  |  | ||||||
|  | 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 you're not the author, echo | ||||||
|         if author_id != self.uid: |         if event.author.id != session.user.id: | ||||||
|             self.sendMessage(message, thread_id=thread_id, thread_type=thread_type) |             event.thread.send_text(event.message.text) | ||||||
|  |  | ||||||
| client = EchoBot("<email>", "<password>") |  | ||||||
| client.listen() |  | ||||||
|   | |||||||
| @@ -1,46 +1,50 @@ | |||||||
| # -*- coding: UTF-8 -*- | import fbchat | ||||||
|  |  | ||||||
| from fbchat import Client | session = fbchat.Session.login("<email>", "<password>") | ||||||
| from fbchat.models import * |  | ||||||
|  |  | ||||||
| client = Client('<email>', '<password>') | client = fbchat.Client(session=session) | ||||||
|  |  | ||||||
| # Fetches a list of all users you're currently chatting with, as `User` objects | # Fetches a list of all users you're currently chatting with, as `User` objects | ||||||
| users = client.fetchAllUsers() | users = client.fetch_all_users() | ||||||
|  |  | ||||||
| print("users' IDs: {}".format(user.uid for user in users)) | print("users' IDs: {}".format([user.id for user in users])) | ||||||
| print("users' names: {}".format(user.name 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 | # If we have a user id, we can use `fetch_user_info` to fetch a `User` object | ||||||
| user = client.fetchUserInfo('<user id>')['<user id>'] | user = client.fetch_user_info("<user id>")["<user id>"] | ||||||
| # We can also query both mutiple users together, which returns list of `User` objects | # 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("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: | # 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 name: {}".format(user.name)) | ||||||
| print("user's photo: {}".format(user.photo)) | print("user's photo: {}".format(user.photo)) | ||||||
| print("Is user client's friend: {}".format(user.is_friend)) | print("Is user client's friend: {}".format(user.is_friend)) | ||||||
|  |  | ||||||
|  |  | ||||||
| # Fetches a list of the 20 top threads you're currently chatting with | # 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 | # 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)) | 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 | # 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 | # Since the message come in reversed order, reverse them | ||||||
| messages.reverse() | messages.reverse() | ||||||
|  |  | ||||||
| @@ -49,16 +53,17 @@ for message in messages: | |||||||
|     print(message.text) |     print(message.text) | ||||||
|  |  | ||||||
|  |  | ||||||
| # If we have a thread id, we can use `fetchThreadInfo` to fetch a `Thread` object | # `search_for_threads` searches works like `search_for_users`, but gives us a list of threads instead | ||||||
| thread = client.fetchThreadInfo('<thread id>')['<thread id>'] | thread = client.search_for_threads("<name of thread>")[0] | ||||||
| print("thread's name: {}".format(thread.name)) | 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` | # 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 | session = fbchat.Session.login("<email>", "<password>") | ||||||
| from fbchat.models import * |  | ||||||
|  |  | ||||||
| client = Client("<email>", "<password>") | client = fbchat.Client(session) | ||||||
|  |  | ||||||
| thread_id = '1234567890' | thread = session.user | ||||||
| thread_type = ThreadType.GROUP | # thread = fbchat.User(session=session, id="0987654321") | ||||||
|  | # thread = fbchat.Group(session=session, id="1234567890") | ||||||
|  |  | ||||||
| # Will send a message to the thread | # 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 | # 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 `👍` | # 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>` | # 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 | # 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) | 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 | # Only do these actions if the thread is a group | ||||||
| if thread_type == ThreadType.GROUP: | if isinstance(thread, fbchat.Group): | ||||||
|     # Will remove the user with ID `<user id>` from the thread |     # Will remove the user with ID `<user id>` from the group | ||||||
|     client.removeUserFromGroup('<user id>', thread_id=thread_id) |     thread.remove_participant("<user id>") | ||||||
|  |     # Will add the users with IDs `<1st user id>`, `<2nd user id>` and `<3th user id>` to the group | ||||||
|     # Will add the user with ID `<user id>` to the thread |     thread.add_participants(["<1st user id>", "<2nd user id>", "<3rd user id>"]) | ||||||
|     client.addUsersToGroup('<user id>', thread_id=thread_id) |     # Will change the title of the group to `<title>` | ||||||
|  |     thread.set_title("<title>") | ||||||
|     # 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) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Will change the nickname of the user `<user_id>` to `<new nickname>` | # 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) | thread.set_nickname(fbchat.User(session=session, id="<user id>"), "<new nickname>") | ||||||
|  |  | ||||||
| # Will change the title of the thread to `<title>` | # Will set the typing status of the thread | ||||||
| client.changeThreadTitle('<title>', thread_id=thread_id, thread_type=thread_type) | thread.start_typing() | ||||||
|  |  | ||||||
| # Will set the typing status of the thread to `TYPING` | # Will change the thread color to #0084ff | ||||||
| client.setTypingStatus(TypingStatus.TYPING, thread_id=thread_id, thread_type=thread_type) | thread.set_color("#0084ff") | ||||||
|  |  | ||||||
| # Will change the thread color to `MESSENGER_BLUE` |  | ||||||
| client.changeThreadColor(ThreadColor.MESSENGER_BLUE, thread_id=thread_id) |  | ||||||
|  |  | ||||||
| # Will change the thread emoji to `👍` | # 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 | # Will react to a message with a 😍 emoji | ||||||
| client.reactToMessage('<message id>', MessageReaction.LOVE) | message.react("😍") | ||||||
|   | |||||||
| @@ -1,54 +1,92 @@ | |||||||
| # -*- coding: UTF-8 -*- | # 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! | ||||||
| from fbchat import log, Client | import fbchat | ||||||
| from fbchat.models import * | import blinker | ||||||
|  |  | ||||||
| # Change this to your group id | # Change this to your group id | ||||||
| old_thread_id = '1234567890' | old_thread_id = "1234567890" | ||||||
|  |  | ||||||
| # Change these to match your liking | # Change these to match your liking | ||||||
| old_color = ThreadColor.MESSENGER_BLUE | old_color = "#0084ff" | ||||||
| old_emoji = '👍' | old_emoji = "👍" | ||||||
| old_title = 'Old group chat name' | old_title = "Old group chat name" | ||||||
| old_nicknames = { | old_nicknames = { | ||||||
|     '12345678901': "User nr. 1's nickname", |     "12345678901": "User nr. 1's nickname", | ||||||
|     '12345678902': "User nr. 2's nickname", |     "12345678902": "User nr. 2's nickname", | ||||||
|     '12345678903': "User nr. 3's nickname", |     "12345678903": "User nr. 3's nickname", | ||||||
|     '12345678904': "User nr. 4's nickname" |     "12345678904": "User nr. 4's nickname", | ||||||
| } | } | ||||||
|  |  | ||||||
| class KeepBot(Client): | # Create a blinker signal | ||||||
|     def onColorChange(self, author_id, new_color, thread_id, thread_type, **kwargs): | events = blinker.Signal() | ||||||
|         if old_thread_id == thread_id and old_color != new_color: |  | ||||||
|             log.info("{} changed the thread color. It will be changed back".format(author_id)) |  | ||||||
|             self.changeThreadColor(old_color, thread_id=thread_id) |  | ||||||
|  |  | ||||||
|     def onEmojiChange(self, author_id, new_emoji, thread_id, thread_type, **kwargs): | # Register various event handlers on the signal | ||||||
|         if old_thread_id == thread_id and new_emoji != old_emoji: | @events.connect_via(fbchat.ColorSet) | ||||||
|             log.info("{} changed the thread emoji. It will be changed back".format(author_id)) | def on_color_set(sender, event: fbchat.ColorSet): | ||||||
|             self.changeThreadEmoji(old_emoji, thread_id=thread_id) |     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): | @events.connect_via(fbchat.EmojiSet) | ||||||
|         # No point in trying to add ourself | def on_emoji_set(sender, event: fbchat.EmojiSet): | ||||||
|         if old_thread_id == thread_id and removed_id != self.uid and author_id != self.uid: |     if old_thread_id != event.thread.id: | ||||||
|             log.info("{} got removed. They will be re-added".format(removed_id)) |         return | ||||||
|             self.addUsersToGroup(removed_id, thread_id=thread_id) |     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): | @events.connect_via(fbchat.TitleSet) | ||||||
|         if old_thread_id == thread_id and changed_for in old_nicknames and old_nicknames[changed_for] != new_nickname: | def on_title_set(sender, event: fbchat.TitleSet): | ||||||
|             log.info("{} changed {}'s' nickname. It will be changed back".format(author_id, changed_for)) |     if old_thread_id != event.thread.id: | ||||||
|             self.changeNickname(old_nicknames[changed_for], changed_for, thread_id=thread_id, thread_type=thread_type) |         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 on_message(event): | ||||||
|     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 | ||||||
|         # 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): | ||||||
|         if message == 'Remove me!' and thread_type == ThreadType.GROUP: |         return | ||||||
|             log.info('{} will be removed from {}'.format(author_id, thread_id)) |     if event.message.text == "Remove me!": | ||||||
|             self.removeUserFromGroup(author_id, thread_id=thread_id) |         print(f"{event.author.id} will be removed from {event.thread.id}") | ||||||
|         else: |         event.thread.remove_participant(event.author.id) | ||||||
|             # 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) |  | ||||||
|  |  | ||||||
| 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
									
								
							
							
						
						
									
										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 | Copyright: | ||||||
| from datetime import datetime |     (c) 2015 - 2018 by Taehoon Kim | ||||||
| from .client import * |     (c) 2018 - 2020 by Mads Marquart | ||||||
|  |  | ||||||
|  | License: | ||||||
| """ |     BSD 3-Clause, see LICENSE for more details. | ||||||
|     fbchat |  | ||||||
|     ~~~~~~ |  | ||||||
|  |  | ||||||
|     Facebook Chat (Messenger) for Python |  | ||||||
|  |  | ||||||
|     :copyright: (c) 2015 by Taehoon Kim. |  | ||||||
|     :license: BSD, see LICENSE for more details. |  | ||||||
| """ | """ | ||||||
|  |  | ||||||
|  | import logging as _logging | ||||||
|  |  | ||||||
| __copyright__ = 'Copyright 2015 - {} by Taehoon Kim'.format(datetime.now().year) | # Set default logging handler to avoid "No handler found" warnings. | ||||||
| __version__ = '1.0.24' | _logging.getLogger(__name__).addHandler(_logging.NullHandler()) | ||||||
| __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' |  | ||||||
|  |  | ||||||
| __all__ = [ | # The order of these is somewhat significant, e.g. User has to be imported after Thread! | ||||||
|     'Client', | 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.0a4" | ||||||
|  |  | ||||||
|  | __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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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"), | ||||||
|  |         ) | ||||||
							
								
								
									
										488
									
								
								fbchat/_models/_message.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										488
									
								
								fbchat/_models/_message.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,488 @@ | |||||||
|  | import attr | ||||||
|  | import datetime | ||||||
|  | import enum | ||||||
|  | from string import Formatter | ||||||
|  | from . import _attachment, _location, _file, _quick_reply, _sticker | ||||||
|  | from .._common import log, attrs_default | ||||||
|  | from .. import _exception, _util | ||||||
|  | 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. | ||||||
|  |  | ||||||
|  |         Currently, you can use "❤", "😍", "😆", "😮", "😢", "😠", "👍" or "👎". It | ||||||
|  |         should be possible to add support for more, but we haven't figured that out yet. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             reaction: Reaction emoji to use, or if ``None``, removes reaction. | ||||||
|  |  | ||||||
|  |         Example: | ||||||
|  |             >>> message.react("😍") | ||||||
|  |         """ | ||||||
|  |         if reaction and reaction not in SENDABLE_REACTIONS: | ||||||
|  |             raise ValueError( | ||||||
|  |                 "Invalid reaction! Please use one of: {}".format(SENDABLE_REACTIONS) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         data = { | ||||||
|  |             "action": "ADD_REACTION" if reaction else "REMOVE_REACTION", | ||||||
|  |             "client_mutation_id": "1", | ||||||
|  |             "actor_id": self.session.user.id, | ||||||
|  |             "message_id": self.id, | ||||||
|  |             "reaction": reaction, | ||||||
|  |         } | ||||||
|  |         data = { | ||||||
|  |             "doc_id": 1491398900900362, | ||||||
|  |             "variables": _util.json_minimal({"data": data}), | ||||||
|  |         } | ||||||
|  |         j = self.session._payload_post("/webgraphql/mutation", data) | ||||||
|  |         _exception.handle_graphql_errors(j) | ||||||
|  |  | ||||||
|  |     def fetch(self) -> "MessageData": | ||||||
|  |         """Fetch fresh `MessageData` object. | ||||||
|  |  | ||||||
|  |         Example: | ||||||
|  |             >>> message = message.fetch() | ||||||
|  |             >>> message.text | ||||||
|  |             "The message text" | ||||||
|  |         """ | ||||||
|  |         message_info = self.thread._forced_fetch(self.id).get("message") | ||||||
|  |         return MessageData._from_graphql(self.thread, message_info) | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def format_mentions(text, *args, **kwargs): | ||||||
|  |         """Like `str.format`, but takes tuples with a thread id and text instead. | ||||||
|  |  | ||||||
|  |         Return a tuple, with the formatted string and relevant mentions. | ||||||
|  |  | ||||||
|  |         >>> Message.format_mentions("Hey {!r}! My name is {}", ("1234", "Peter"), ("4321", "Michael")) | ||||||
|  |         ("Hey 'Peter'! My name is Michael", [Mention(thread_id=1234, offset=4, length=7), Mention(thread_id=4321, offset=24, length=7)]) | ||||||
|  |  | ||||||
|  |         >>> Message.format_mentions("Hey {p}! My name is {}", ("1234", "Michael"), p=("4321", "Peter")) | ||||||
|  |         ('Hey Peter! My name is Michael', [Mention(thread_id=4321, offset=4, length=5), Mention(thread_id=1234, offset=22, length=7)]) | ||||||
|  |         """ | ||||||
|  |         result = "" | ||||||
|  |         mentions = list() | ||||||
|  |         offset = 0 | ||||||
|  |         f = Formatter() | ||||||
|  |         field_names = [field_name[1] for field_name in f.parse(text)] | ||||||
|  |         automatic = "" in field_names | ||||||
|  |         i = 0 | ||||||
|  |  | ||||||
|  |         for (literal_text, field_name, format_spec, conversion) in f.parse(text): | ||||||
|  |             offset += len(literal_text) | ||||||
|  |             result += literal_text | ||||||
|  |  | ||||||
|  |             if field_name is None: | ||||||
|  |                 continue | ||||||
|  |  | ||||||
|  |             if field_name == "": | ||||||
|  |                 field_name = str(i) | ||||||
|  |                 i += 1 | ||||||
|  |             elif automatic and field_name.isdigit(): | ||||||
|  |                 raise ValueError( | ||||||
|  |                     "cannot switch from automatic field numbering to manual field specification" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |             thread_id, name = f.get_field(field_name, args, kwargs)[0] | ||||||
|  |  | ||||||
|  |             if format_spec: | ||||||
|  |                 name = f.format_field(name, format_spec) | ||||||
|  |             if conversion: | ||||||
|  |                 name = f.convert_field(name, conversion) | ||||||
|  |  | ||||||
|  |             result += name | ||||||
|  |             mentions.append( | ||||||
|  |                 Mention(thread_id=thread_id, offset=offset, length=len(name)) | ||||||
|  |             ) | ||||||
|  |             offset += len(name) | ||||||
|  |  | ||||||
|  |         return result, mentions | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @attrs_default | ||||||
|  | class MessageSnippet(Message): | ||||||
|  |     """Represents data in a Facebook message snippet. | ||||||
|  |  | ||||||
|  |     Inherits `Message`. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     #: ID of the sender | ||||||
|  |     author = attr.ib(type=str) | ||||||
|  |     #: 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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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, | ||||||
|  |         ) | ||||||
							
								
								
									
										513
									
								
								fbchat/_session.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										513
									
								
								fbchat/_session.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,513 @@ | |||||||
|  | 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":)|(?:require\("ServerJSDefine"\)\)?\.handleDefines\()' | ||||||
|  | ) | ||||||
|  | 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) | ||||||
|  |     if len(define_splits) > 2: | ||||||
|  |         raise _exception.ParseError("Found too many ServerJSDefine", data=define_splits) | ||||||
|  |     # 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 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) | ||||||
|  |         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) | ||||||
|  |         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) | ||||||
|  |     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) | ||||||
|  |     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) | ||||||
|  |     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, | ||||||
|  |             ) | ||||||
|  |         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) | ||||||
|  |             url = r.headers.get("Location") | ||||||
|  |             if not url or not url.startswith("https://www.facebook.com/checkpoint/"): | ||||||
|  |                 raise _exception.ParseError("Failed 2fa flow (2)", data=url) | ||||||
|  |  | ||||||
|  |             r = session.get(url, allow_redirects=False) | ||||||
|  |             url = two_factor_helper(session, r, on_2fa_callback) | ||||||
|  |  | ||||||
|  |             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) | ||||||
|  |             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=False) | ||||||
|  |         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 | ||||||
							
								
								
									
										4
									
								
								fbchat/_threads/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								fbchat/_threads/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | from ._abc import * | ||||||
|  | from ._group import * | ||||||
|  | from ._user import * | ||||||
|  | from ._page import * | ||||||
							
								
								
									
										822
									
								
								fbchat/_threads/_abc.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										822
									
								
								fbchat/_threads/_abc.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,822 @@ | |||||||
|  | import abc | ||||||
|  | import attr | ||||||
|  | import collections | ||||||
|  | import datetime | ||||||
|  | from .._common import log, attrs_default | ||||||
|  | from .. import _util, _exception, _session, _graphql, _models | ||||||
|  | from typing import MutableMapping, Mapping, Any, Iterable, Tuple, Optional | ||||||
|  |  | ||||||
|  |  | ||||||
|  | DEFAULT_COLOR = "#0084ff" | ||||||
|  | SETABLE_COLORS = ( | ||||||
|  |     DEFAULT_COLOR, | ||||||
|  |     "#44bec7", | ||||||
|  |     "#ffc300", | ||||||
|  |     "#fa3c4c", | ||||||
|  |     "#d696bb", | ||||||
|  |     "#6699cc", | ||||||
|  |     "#13cf13", | ||||||
|  |     "#ff7e29", | ||||||
|  |     "#e68585", | ||||||
|  |     "#7646ff", | ||||||
|  |     "#20cef5", | ||||||
|  |     "#67b868", | ||||||
|  |     "#d4a88c", | ||||||
|  |     "#ff5ca1", | ||||||
|  |     "#a695c7", | ||||||
|  |     "#ff7ca8", | ||||||
|  |     "#1adb5b", | ||||||
|  |     "#f01d6a", | ||||||
|  |     "#ff9c19", | ||||||
|  |     "#0edcde", | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ThreadABC(metaclass=abc.ABCMeta): | ||||||
|  |     """Implemented by thread-like classes. | ||||||
|  |  | ||||||
|  |     This is private to implement. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abc.abstractmethod | ||||||
|  |     def session(self) -> _session.Session: | ||||||
|  |         """The session to use when making requests.""" | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abc.abstractmethod | ||||||
|  |     def id(self) -> str: | ||||||
|  |         """The unique identifier of the thread.""" | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     @abc.abstractmethod | ||||||
|  |     def _to_send_data(self) -> MutableMapping[str, str]: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     # Note: | ||||||
|  |     # You can go out of Facebook's spec with `self.session._do_send_request`! | ||||||
|  |     # | ||||||
|  |     # A few examples: | ||||||
|  |     # - You can send a sticker and an emoji at the same time | ||||||
|  |     # - You can wave, send a sticker and text at the same time | ||||||
|  |     # - You can reply to a message with a sticker | ||||||
|  |     # | ||||||
|  |     # We won't support those use cases, it'll make for a confusing API! | ||||||
|  |     # If we absolutely need to in the future, we can always add extra functionality | ||||||
|  |  | ||||||
|  |     @abc.abstractmethod | ||||||
|  |     def _copy(self) -> "ThreadABC": | ||||||
|  |         """It may or may not be a good idea to attach the current thread to new objects. | ||||||
|  |  | ||||||
|  |         So for now, we use this method to create a new thread. | ||||||
|  |  | ||||||
|  |         This should return the minimal representation of the thread (e.g. not UserData). | ||||||
|  |         """ | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     def fetch(self): | ||||||
|  |         # TODO: This | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     def wave(self, first: bool = True) -> str: | ||||||
|  |         """Wave hello to the thread. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             first: Whether to wave first or wave back | ||||||
|  |  | ||||||
|  |         Example: | ||||||
|  |             Wave back to the thread. | ||||||
|  |  | ||||||
|  |             >>> thread.wave(False) | ||||||
|  |         """ | ||||||
|  |         data = self._to_send_data() | ||||||
|  |         data["action_type"] = "ma-type:user-generated-message" | ||||||
|  |         data["lightweight_action_attachment[lwa_state]"] = ( | ||||||
|  |             "INITIATED" if first else "RECIPROCATED" | ||||||
|  |         ) | ||||||
|  |         data["lightweight_action_attachment[lwa_type]"] = "WAVE" | ||||||
|  |         message_id, thread_id = self.session._do_send_request(data) | ||||||
|  |         return message_id | ||||||
|  |  | ||||||
|  |     def send_text( | ||||||
|  |         self, | ||||||
|  |         text: str, | ||||||
|  |         mentions: Iterable["_models.Mention"] = None, | ||||||
|  |         files: Iterable[Tuple[str, str]] = None, | ||||||
|  |         reply_to_id: str = None, | ||||||
|  |     ) -> str: | ||||||
|  |         """Send a message to the thread. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             text: Text to send | ||||||
|  |             mentions: Optional mentions | ||||||
|  |             files: Optional tuples, each containing an uploaded file's ID and mimetype. | ||||||
|  |                 See `ThreadABC.send_files` for an example. | ||||||
|  |             reply_to_id: Optional message to reply to | ||||||
|  |  | ||||||
|  |         Example: | ||||||
|  |             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 | ||||||
|  |  | ||||||
|  |         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) | ||||||
|  |  | ||||||
|  |     # 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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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) | ||||||
							
								
								
									
										1799
									
								
								fbchat/client.py
									
									
									
									
									
								
							
							
						
						
									
										1799
									
								
								fbchat/client.py
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,294 +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 FBchatException('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'): |  | ||||||
|         uid = thread.get('thread_key', {}).get('other_user_id') or thread.get('id') |  | ||||||
|         pc = info['participant_customizations'] |  | ||||||
|         if len(pc) > 0: |  | ||||||
|             if pc[0].get('participant_id') == uid: |  | ||||||
|                 rtn['nickname'] = pc[0].get('nickname') |  | ||||||
|             else: |  | ||||||
|                 rtn['own_nickname'] = pc[0].get('nickname') |  | ||||||
|         if len(pc) > 1: |  | ||||||
|             if pc[1].get('participant_id') == uid: |  | ||||||
|                 rtn['nickname'] = pc[1].get('nickname') |  | ||||||
|             else: |  | ||||||
|                 rtn['own_nickname'] = pc[1].get('nickname') |  | ||||||
|     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'), |  | ||||||
|         message_count=user.get('messages_count') |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
| 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'), |  | ||||||
|         message_count=group.get('messages_count') |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
| 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'), |  | ||||||
|         message_count=page.get('messages_count') |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
| 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): |  | ||||||
|     content = strip_to_json(content) # Usually only needed in some error cases |  | ||||||
|     try: |  | ||||||
|         j = json.loads(content, cls=ConcatJSONDecoder) |  | ||||||
|     except Exception: |  | ||||||
|         raise FBchatException('Error while parsing JSON: {}'.format(repr(content))) |  | ||||||
|  |  | ||||||
|     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 FBchatUserError('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 |  | ||||||
							
								
								
									
										259
									
								
								fbchat/models.py
									
									
									
									
									
								
							
							
						
						
									
										259
									
								
								fbchat/models.py
									
									
									
									
									
								
							| @@ -1,259 +0,0 @@ | |||||||
| # -*- coding: UTF-8 -*- |  | ||||||
|  |  | ||||||
| from __future__ import unicode_literals |  | ||||||
| import enum |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class FBchatException(Exception): |  | ||||||
|     """Custom exception thrown by fbchat. All exceptions in the fbchat module inherits this""" |  | ||||||
|  |  | ||||||
| class FBchatFacebookError(FBchatException): |  | ||||||
|     #: The error code that Facebook returned |  | ||||||
|     fb_error_code = str |  | ||||||
|     #: The error message that Facebook returned (In the user's own language) |  | ||||||
|     fb_error_message = str |  | ||||||
|     #: The status code that was sent in the http response (eg. 404) (Usually only set if not successful, aka. not 200) |  | ||||||
|     request_status_code = int |  | ||||||
|     def __init__(self, message, fb_error_code=None, fb_error_message=None, request_status_code=None): |  | ||||||
|         super(FBchatFacebookError, self).__init__(message) |  | ||||||
|         """Thrown by fbchat when Facebook returns an error""" |  | ||||||
|         self.fb_error_code = str(fb_error_code) |  | ||||||
|         self.fb_error_message = fb_error_message |  | ||||||
|         self.request_status_code = request_status_code |  | ||||||
|  |  | ||||||
| class FBchatUserError(FBchatException): |  | ||||||
|     """Thrown by fbchat when wrong values are entered""" |  | ||||||
|  |  | ||||||
| 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 |  | ||||||
|     #: Number of messages in the thread |  | ||||||
|     message_count = int |  | ||||||
|     def __init__(self, _type, uid, photo=None, name=None, last_message_timestamp=None, message_count=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 |  | ||||||
|         self.message_count = message_count |  | ||||||
|  |  | ||||||
|     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 ThreadLocation(Enum): |  | ||||||
|     """Used to specify where a thread is located (inbox, pending, archived, other).""" |  | ||||||
|     INBOX = 'inbox' |  | ||||||
|     PENDING = 'pending' |  | ||||||
|     ARCHIVED = 'action:archived' |  | ||||||
|     OTHER = 'other' |  | ||||||
|  |  | ||||||
| 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
									
								
							
							
						
						
									
										0
									
								
								fbchat/py.typed
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										184
									
								
								fbchat/utils.py
									
									
									
									
									
								
							
							
						
						
									
										184
									
								
								fbchat/utils.py
									
									
									
									
									
								
							| @@ -1,184 +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': '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/" |  | ||||||
|  |  | ||||||
|     pull_channel = 0 |  | ||||||
|  |  | ||||||
|     def change_pull_channel(self, channel=None): |  | ||||||
|         if channel is None: |  | ||||||
|             self.pull_channel = (self.pull_channel + 1) % 5 # Pull channel will be 0-4 |  | ||||||
|         else: |  | ||||||
|             self.pull_channel = channel |  | ||||||
|         self.STICKY = "https://{}-edge-chat.facebook.com/pull".format(self.pull_channel) |  | ||||||
|         self.PING = "https://{}-edge-chat.facebook.com/active_ping".format(self.pull_channel) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| facebookEncoding = 'UTF-8' |  | ||||||
|  |  | ||||||
| def now(): |  | ||||||
|     return int(time()*1000) |  | ||||||
|  |  | ||||||
| def strip_to_json(text): |  | ||||||
|     try: |  | ||||||
|         return text[text.index('{'):] |  | ||||||
|     except ValueError: |  | ||||||
|         raise FBchatException('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 j.get('error') is None: |  | ||||||
|         return |  | ||||||
|     if 'errorDescription' in j: |  | ||||||
|         # 'errorDescription' is in the users own language! |  | ||||||
|         raise FBchatFacebookError('Error #{} when sending request: {}'.format(j['error'], j['errorDescription']), fb_error_code=j['error'], fb_error_message=j['errorDescription']) |  | ||||||
|     elif 'debug_info' in j['error'] and 'code' in j['error']: |  | ||||||
|         raise FBchatFacebookError('Error #{} when sending request: {}'.format(j['error']['code'], repr(j['error']['debug_info'])), fb_error_code=j['error']['code'], fb_error_message=j['error']['debug_info']) |  | ||||||
|     else: |  | ||||||
|         raise FBchatFacebookError('Error {} when sending request'.format(j['error']), fb_error_code=j['error']) |  | ||||||
|  |  | ||||||
| def check_request(r, as_json=True): |  | ||||||
|     if not r.ok: |  | ||||||
|         raise FBchatFacebookError('Error when sending request: Got {} response'.format(r.status_code), request_status_code=r.status_code) |  | ||||||
|  |  | ||||||
|     content = get_decoded_r(r) |  | ||||||
|  |  | ||||||
|     if content is None or len(content) == 0: |  | ||||||
|         raise FBchatFacebookError('Error when sending request: Got empty response') |  | ||||||
|  |  | ||||||
|     if as_json: |  | ||||||
|         content = strip_to_json(content) |  | ||||||
|         try: |  | ||||||
|             j = json.loads(content) |  | ||||||
|         except ValueError: |  | ||||||
|             raise FBchatFacebookError('Error while parsing JSON: {}'.format(repr(content))) |  | ||||||
|         check_json(j) |  | ||||||
|         return j |  | ||||||
|     else: |  | ||||||
|         return content |  | ||||||
							
								
								
									
										64
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | |||||||
|  | [tool.black] | ||||||
|  | line-length = 88 | ||||||
|  | target-version = ['py36', 'py37', 'py38'] | ||||||
|  |  | ||||||
|  | [build-system] | ||||||
|  | requires = ["flit"] | ||||||
|  | build-backend = "flit.buildapi" | ||||||
|  |  | ||||||
|  | [tool.flit.metadata] | ||||||
|  | module = "fbchat" | ||||||
|  | author = "Taehoon Kim" | ||||||
|  | author-email = "carpedm20@gmail.com" | ||||||
|  | maintainer = "Mads Marquart" | ||||||
|  | maintainer-email = "madsmtm@gmail.com" | ||||||
|  | home-page = "https://github.com/carpedm20/fbchat/" | ||||||
|  | requires = [ | ||||||
|  |     "attrs>=19.1", | ||||||
|  |     "requests~=2.19", | ||||||
|  |     "beautifulsoup4~=4.0", | ||||||
|  |     "paho-mqtt~=1.5", | ||||||
|  | ] | ||||||
|  | description-file = "README.rst" | ||||||
|  | classifiers = [ | ||||||
|  |     "Development Status :: 3 - Alpha", | ||||||
|  |     "Intended Audience :: Developers", | ||||||
|  |     "Intended Audience :: Information Technology", | ||||||
|  |     "License :: OSI Approved :: BSD License", | ||||||
|  |     "Operating System :: OS Independent", | ||||||
|  |     "Natural Language :: English", | ||||||
|  |     "Programming Language :: Python", | ||||||
|  |     "Programming Language :: Python :: 3", | ||||||
|  |     "Programming Language :: Python :: 3 :: Only", | ||||||
|  |     "Programming Language :: Python :: 3.5", | ||||||
|  |     "Programming Language :: Python :: 3.6", | ||||||
|  |     "Programming Language :: Python :: 3.7", | ||||||
|  |     "Programming Language :: Python :: 3.8", | ||||||
|  |     "Programming Language :: Python :: Implementation :: CPython", | ||||||
|  |     "Programming Language :: Python :: Implementation :: PyPy", | ||||||
|  |     "Topic :: Communications :: Chat", | ||||||
|  |     "Topic :: Internet :: WWW/HTTP", | ||||||
|  |     "Topic :: Internet :: WWW/HTTP :: Dynamic Content", | ||||||
|  |     "Topic :: Software Development :: Libraries", | ||||||
|  |     "Topic :: Software Development :: Libraries :: Python Modules", | ||||||
|  | ] | ||||||
|  | requires-python = ">=3.5, <4.0" | ||||||
|  | keywords = "Facebook FB Messenger Library Chat Api Bot" | ||||||
|  | license = "BSD 3-Clause" | ||||||
|  |  | ||||||
|  | [tool.flit.metadata.urls] | ||||||
|  | Documentation = "https://fbchat.readthedocs.io/" | ||||||
|  | Repository = "https://github.com/carpedm20/fbchat/" | ||||||
|  |  | ||||||
|  | [tool.flit.metadata.requires-extra] | ||||||
|  | test = [ | ||||||
|  |     "pytest>=4.3,<6.0", | ||||||
|  | ] | ||||||
|  | docs = [ | ||||||
|  |     "sphinx~=2.0", | ||||||
|  |     "sphinxcontrib-spelling~=4.0", | ||||||
|  |     "sphinx-autodoc-typehints~=1.10", | ||||||
|  | ] | ||||||
|  | lint = [ | ||||||
|  |     "black", | ||||||
|  | ] | ||||||
							
								
								
									
										10
									
								
								pytest.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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,4 +0,0 @@ | |||||||
| requests |  | ||||||
| lxml |  | ||||||
| beautifulsoup4 |  | ||||||
| enum34; python_version == '2.7' |  | ||||||
							
								
								
									
										83
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										83
									
								
								setup.py
									
									
									
									
									
								
							| @@ -1,83 +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() |  | ||||||
|  |  | ||||||
| requirements = [ |  | ||||||
|     'requests', |  | ||||||
|     'lxml', |  | ||||||
|     'beautifulsoup4', |  | ||||||
|     "enum34; python_version == '2.7'" |  | ||||||
| ] |  | ||||||
|  |  | ||||||
| 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
									
									
									
									
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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 | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user