Compare commits
2019 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccb349f3d2 | |||
| 4768065e72 | |||
| 64bf6bfe90 | |||
| aab48f0dbe | |||
| b00e2d8955 | |||
| e3bb26aee1 | |||
| 7c2c72bbde | |||
| 2ffbde7448 | |||
| 2a0da7801a | |||
| eaf387abfe | |||
| 64d80c3d1d | |||
| c78b1abd2d | |||
| 12f900a7bd | |||
| cdb77f938a | |||
| 5a1a478992 | |||
| d2a06ebbbe | |||
| e6243d8935 | |||
| 9e1c42a992 | |||
| 6eacf38d74 | |||
| 65fcf712d3 | |||
| 8512cfe6a6 | |||
| 7a6d1bf17a | |||
| 18f831553d | |||
| dce0c4dbe1 | |||
| ac2a2c2980 | |||
| b9f0962881 | |||
| d7864fcd3a | |||
| 0f0b21b22c | |||
| 1dd11f1086 | |||
| 3f155672a7 | |||
| b761f04621 | |||
| 95db7a6d0d | |||
| 8b3707b0ee | |||
| 4d46c5ee7c | |||
| 009ce8c0d3 | |||
| a06b7d607d | |||
| 659b0f82ae | |||
| 53dec19878 | |||
| a5b1927acb | |||
| 0988de1267 | |||
| 5c1975808a | |||
| 2b79d842a4 | |||
| 6e08539d57 | |||
| 634cec5ba9 | |||
| 506e13f6b8 | |||
| e55e596d68 | |||
| 11495e6e7e | |||
| 53574754be | |||
| 3f725b37b9 | |||
| 2ad36b6366 | |||
| fe119542f8 | |||
| 98f24f9b5e | |||
| 60e3cf9c01 | |||
| cc32d48fea | |||
| 117c5cd0ce | |||
| 41f2166feb | |||
| abbcacec4b | |||
| 92fdf7b8e9 | |||
| f13af2ef54 | |||
| 0172a5733b | |||
| 8350230693 | |||
| c3216f1e4d | |||
| 0bee8da0f8 | |||
| 795b27275f | |||
| ba7d51e785 | |||
| cbff082e4d | |||
| 9b92aa3d50 | |||
| e96f12cfea | |||
| e7099d26f3 | |||
| 8b68fdce79 | |||
| 1dc01bcffd | |||
| dbab7f0ee4 | |||
| 835afb0100 | |||
| 693ced7dea | |||
| b6c7b0e78b | |||
| 3b939f423a | |||
| f4555782cf | |||
| 1ff046db0b | |||
| a44cc41933 | |||
| 770b3b8d8c | |||
| 7630340ffc | |||
| dc2b16e1a6 | |||
| f7b220f711 | |||
| e37619e826 | |||
| 0dc5045b00 | |||
| c8c5c7d272 | |||
| c358222ab4 | |||
| fdebeb9ca8 | |||
| bd6d40cad0 | |||
| 358318c734 | |||
| a0323a5233 | |||
| 3500590f11 | |||
| 011894f7b4 | |||
| af9ce963a9 | |||
| 50a1c21fd1 | |||
| 606bf92ab1 | |||
| 9b7ee5e2c3 | |||
| e4195fadb4 | |||
| 7cf65b6f6a | |||
| a5a3b9f380 | |||
| 58d99a806a | |||
| acf716c031 | |||
| 8de8170619 | |||
| b2f99ec5c0 | |||
| ec960a7372 | |||
| 6d085f477e | |||
| e68ef24657 | |||
| f7cbf751a0 | |||
| d124008443 | |||
| 190e65edfb | |||
| 4f4680b19a | |||
| c46a3189e0 | |||
| b084627248 | |||
| ce70aacdb8 | |||
| b6aff6784f | |||
| 5d05d7ab05 | |||
| 5c37b186d8 | |||
| 0881e76205 | |||
| c5cdde83e4 | |||
| 8bd4ff8f82 | |||
| 4a538f77ef | |||
| f64b605443 | |||
| dbe9be2102 | |||
| 0727857ed0 | |||
| c97c5f6bec | |||
| 01357fe5df | |||
| d8188743ba | |||
| 6d373885d2 | |||
| 1cd589dbd1 | |||
| a96bf7ed95 | |||
| bb405b4773 | |||
| b43adb6bab | |||
| abae7b2854 | |||
| 472b9df44c | |||
| bec7ee8f5e | |||
| ae5f2f3093 | |||
| 3910e44639 | |||
| 50aefd6897 | |||
| b17bb0d5c7 | |||
| 64724aa654 | |||
| 800c15f7b7 | |||
| 7f71e5f09c | |||
| bfe5999951 | |||
| b1b5745033 | |||
| 98936fdf7a | |||
| 7baed1c77b | |||
| b695e0b4ea | |||
| 326906644e | |||
| 0122ab91d6 | |||
| 0d818303f4 | |||
| 7fa51da335 | |||
| 49d99aff82 | |||
| cfd9b74d34 | |||
| d067348ac5 | |||
| 84392278c2 | |||
| 0aed201869 | |||
| 62efa2e7b9 | |||
| 58f40aeba5 | |||
| 29000146ba | |||
| a9cb55d109 | |||
| f7ae7ba804 | |||
| 0e45edd1f4 | |||
| 7fb4539885 | |||
| 42465f1aca | |||
| 5bf7461566 | |||
| a84dd2f30c | |||
| 67adededff | |||
| e5914196c5 | |||
| 189dbdfc52 | |||
| 7738fc21f5 | |||
| 4511c82cb0 | |||
| 6af986ded5 | |||
| 93fe3cb0ea | |||
| 52b2373528 | |||
| a59c755dd8 | |||
| 4793b01a29 | |||
| e8114ff5ad | |||
| e597eace68 | |||
| 4071502854 | |||
| c7a7f6ec20 | |||
| 8f3cc2e28e | |||
| 6700403118 | |||
| f4830c71d8 | |||
| 78ba8e4d45 | |||
| 2b1cfae52f | |||
| 274141a1b0 | |||
| cac1f5acde | |||
| 56fe704934 | |||
| 3696c9cff4 | |||
| d83c0ede15 | |||
| b5d6e2ac6b | |||
| 37d34a4ab6 | |||
| baba8bd712 | |||
| 7573e3d5a7 | |||
| a887f26023 | |||
| ced0a2d067 | |||
| 09227510bc | |||
| dc2a422bbe | |||
| 49bb93bdc2 | |||
| a3ec7f7c33 | |||
| cd660472d8 | |||
| 20446d0d7d | |||
| 769a397a03 | |||
| 1dde2a4a77 | |||
| 85e1a6dc05 | |||
| e9d95a6e9a | |||
| e1497999d6 | |||
| dd63436149 | |||
| dd34256f3d | |||
| 7f17c2728e | |||
| 4342635b8a | |||
| 80ccbeb449 | |||
| 283dfc5c77 | |||
| d00de62ee7 | |||
| 1a2fd67ee9 | |||
| 4873ed77ff | |||
| d6a8e6a648 | |||
| 095bd65d51 | |||
| cd9970055f | |||
| 3663f91c8a | |||
| 0c3749a2ca | |||
| ba4dd48d5a | |||
| c1d92ce051 | |||
| ae05331420 | |||
| ef65f9f1ea | |||
| 29d8c1b7dd | |||
| 4775e67476 | |||
| 43b230148b | |||
| d03260c4a7 | |||
| 042304f147 | |||
| de2e87ed52 | |||
| 581ba79c84 | |||
| 390f9f422e | |||
| f80d6de818 | |||
| 69fcbd30ce | |||
| 0e3b1b63a9 | |||
| 7f13284b59 | |||
| 4268ee9909 | |||
| 35bf11c158 | |||
| 08703e9efb | |||
| e13502750e | |||
| 0da121aebb | |||
| d07d2af048 | |||
| 76e06d4a33 | |||
| 10f1583da9 | |||
| 48fed1c026 | |||
| abb4671a16 | |||
| 6729a9ad09 | |||
| 96b2afeed1 | |||
| d5f87d2ec1 | |||
| 14b3b1fed7 | |||
| 16a57d78ac | |||
| 548672d243 | |||
| ef23946cbc | |||
| 9b2b691afd | |||
| c04866c854 | |||
| fa28593635 | |||
| 9dd8f30480 | |||
| 19c3121e77 | |||
| 6232a27881 | |||
| 526903cb7c | |||
| 2cac8f8b4a | |||
| c83a361c0b | |||
| c6dd85040c | |||
| 08a2fe9753 | |||
| 2580e28bee | |||
| b7e5078053 | |||
| 8bef95e237 | |||
| 04a10f361a | |||
| fed5752f38 | |||
| 35c161185c | |||
| 1ecb9e8b64 | |||
| 2004085312 | |||
| abd5d058ff | |||
| 09185e8e53 | |||
| 097211cba1 | |||
| 8e7a7db85f | |||
| 55f8d1423b | |||
| 66b84a7b44 | |||
| b38c3cc935 | |||
| b7459ec9eb | |||
| 5eb883e934 | |||
| b9d19a3aad | |||
| 74a5dfccd5 | |||
| b3f9bfb5b3 | |||
| ca46d36998 | |||
| 795a732720 | |||
| 3aa7bdfa91 | |||
| 14f40abeca | |||
| b1f3c4c1db | |||
| 4410415776 | |||
| a38c3e5d00 | |||
| a280c3a4b9 | |||
| 280c74e9cd | |||
| eb5bfb4666 | |||
| fcace69cbd | |||
| f48737c894 | |||
| d359bafb53 | |||
| 44be515705 | |||
| 34683e6d1b | |||
| 8f998cd9cb | |||
| 9706deb27d | |||
| 233516ca4d | |||
| 0051042555 | |||
| 93f55497f4 | |||
| 63e44fb5ad | |||
| 17b69a6eac | |||
| 170b263a6c | |||
| 7a726e36a0 | |||
| 4f12f5103a | |||
| 8341492c9f | |||
| 9e4b6c3c46 | |||
| 597d0e996b | |||
| 4f1482e7b0 | |||
| 4641215e97 | |||
| 2f34ebfed9 | |||
| 3ae88caa80 | |||
| 3462b75c76 | |||
| 6f949b5a1c | |||
| bdf7194691 | |||
| f5bfe421d1 | |||
| 0bc1bd04c5 | |||
| a31787f894 | |||
| e1c0e6dd9a | |||
| ecb3921260 | |||
| e7fe66a23e | |||
| 03fe8bf782 | |||
| 840788c1e5 | |||
| d4f6be8155 | |||
| d10c528895 | |||
| 635345f61d | |||
| e9abeda916 | |||
| b65a1cc60a | |||
| 53bf278f1e | |||
| b16061db34 | |||
| 4b56b6d016 | |||
| 3fb554b934 | |||
| 890851e85e | |||
| a7aa96ef2b | |||
| aa02639759 | |||
| 35f137ccc1 | |||
| a501042f1d | |||
| 7970a678fc | |||
| cb98833590 | |||
| e6c3454e9f | |||
| 4c9555eded | |||
| 38f87becb6 | |||
| 9e2e2421d2 | |||
| 10bc44d17d | |||
| 125be97201 | |||
| d4239d520a | |||
| 399cd5585a | |||
| ac3ce3c097 | |||
| 6280a7bae7 | |||
| af630ecbd1 | |||
| 7a04f298d2 | |||
| 0952df0244 | |||
| 8ac519d1e5 | |||
| a49818b863 | |||
| 31846e7a98 | |||
| 9ab2ee2970 | |||
| c7dd08ecd1 | |||
| fa237a20f7 | |||
| 41279ae996 | |||
| e7b87835b6 | |||
| c96a241794 | |||
| 3c3c3f1dec | |||
| a9a267bc0d | |||
| 05b1eb1214 | |||
| 0f36833e89 | |||
| ea4626107c | |||
| 39c1b685d6 | |||
| 14c784f2a2 | |||
| 11f105c0e7 | |||
| 9e719429e7 | |||
| d2bb02b259 | |||
| 8fbd723bfa | |||
| 7e75c8ef83 | |||
| 9d3e9df57e | |||
| 849f3c6f1e | |||
| 483816cc2b | |||
| 7c13481ede | |||
| eb5ae65402 | |||
| 53e89441b7 | |||
| 48d91fdf76 | |||
| 530bd9e52e | |||
| bdae6dd620 | |||
| 75964a00ed | |||
| 224b01e7a4 | |||
| 5421de8e76 | |||
| a64a178dc3 | |||
| 538f2a2ec0 | |||
| 10b8c4b635 | |||
| b955252a6a | |||
| 6347383788 | |||
| 28d8276554 | |||
| 6480e7925e | |||
| c70ab2a12b | |||
| 09b1e69c0f | |||
| 070bfd4f55 | |||
| 88c3a93526 | |||
| 854f66cb04 | |||
| 1bc3a2538e | |||
| dcc8689835 | |||
| ebc1aa05b1 | |||
| a56f2977b4 | |||
| 36bb741c68 | |||
| f0f92c9dd9 | |||
| e8ee5f174e | |||
| f86ebad162 | |||
| 0712ca5d0c | |||
| dad34f9a3c | |||
| c2e07d5e3f | |||
| 255c1e2e57 | |||
| a680036177 | |||
| 94789daed3 | |||
| e0841e252d | |||
| ab9ff87815 | |||
| 4b08ab6ac0 | |||
| 823eda7589 | |||
| caefda582b | |||
| e1b181ed55 | |||
| cc6a915ef4 | |||
| de4df57278 | |||
| b158ba6b8b | |||
| 571152cb41 | |||
| c82b273155 | |||
| 4bef6ea09e | |||
| 386cfa4cfb | |||
| f4052dcfd3 | |||
| 664d6050df | |||
| 9e868e4614 | |||
| 2743d5375a | |||
| c3fc77c2a8 | |||
| 6c7727d6b5 | |||
| 3ef2cbe102 | |||
| 487f11ffd7 | |||
| 655cd98f27 | |||
| f14c90dc87 | |||
| ee0c2e4f68 | |||
| c8590ca402 | |||
| 964ea69de7 | |||
| 1de97c9ae0 | |||
| 987395914e | |||
| 2a7146d987 | |||
| 71ebb72ede | |||
| dc2216e60b | |||
| 73934a0594 | |||
| 4d33af7f81 | |||
| 80f17d5fbd | |||
| 6c68351e1f | |||
| 83acac5175 | |||
| 46a4b68073 | |||
| 68f4b0e21f | |||
| 32282a242f | |||
| 2129dd803d | |||
| 74d9edf42e | |||
| a1f58cad11 | |||
| bf3e0ec8ab | |||
| 124f0967ed | |||
| 16040adc53 | |||
| 8e994edbde | |||
| 54157de58f | |||
| 7ce3dacf00 | |||
| 6d82ac18b4 | |||
| d6765157ab | |||
| 7bda4f7855 | |||
| e603aa6058 | |||
| 4b5ae24a67 | |||
| 6b6a6ba275 | |||
| 22f44734cf | |||
| 844f31827c | |||
| 21ef73d69c | |||
| 9d80c9e396 | |||
| b316cb131a | |||
| dd64d2c559 | |||
| 1f22aa2072 | |||
| d887887d8b | |||
| 5b7a170ad9 | |||
| 463277def0 | |||
| 40f259da5e | |||
| d1d3c18670 | |||
| 6100335809 | |||
| 869fef0828 | |||
| ada41742a1 | |||
| 1b4416f291 | |||
| 11a832c575 | |||
| 303274acb6 | |||
| b6d3131caf | |||
| 22c3938b52 | |||
| 827116658b | |||
| ca8aff0534 | |||
| 5adb2a6572 | |||
| 69e3a183c7 | |||
| 52c39eefe0 | |||
| 6fa19bbbda | |||
| 8025404958 | |||
| 07a8553b22 | |||
| 15fdd89e3d | |||
| bda33687af | |||
| ea9bd01d06 | |||
| e846fb168c | |||
| 0046975aa5 | |||
| aa7a2d186b | |||
| f195e2cac0 | |||
| b33209fafa | |||
| 265c2835e8 | |||
| 86a77996d4 | |||
| da894bec25 | |||
| cc8dce3959 | |||
| 22488fbc5f | |||
| 3498ed8dc1 | |||
| bcea875e66 | |||
| 7cb70d9753 | |||
| e266d1ac80 | |||
| 0f933f691b | |||
| c6afaf5504 | |||
| 9f6a54be81 | |||
| 229efdd487 | |||
| 4bdd415dbe | |||
| 31dc0259f3 | |||
| 13f21a7c70 | |||
| a573740b9a | |||
| 5448648c32 | |||
| 706d4a5e5c | |||
| df3cd765fe | |||
| bd7c724341 | |||
| f076376caa | |||
| 19a3c8a4d9 | |||
| a9f8a3aa0f | |||
| f91b429c47 | |||
| b0e6dcb1d6 | |||
| 132585de34 | |||
| 4d1cec979b | |||
| 679b4bd157 | |||
| 73d0b189bb | |||
| 48059a3a51 | |||
| 4205047aab | |||
| 03c7028460 | |||
| c75ac58763 | |||
| a85659df9d | |||
| 9a8f356348 | |||
| 9576f48c5b | |||
| 96331761b8 | |||
| 4821865cad | |||
| 7efad4a990 | |||
| 6c44ba487a | |||
| 57b32f6ac6 | |||
| 17e4e20a93 | |||
| 8480c8aa68 | |||
| d14f365fe1 | |||
| 7d9836c86b | |||
| 1c7e626c97 | |||
| 4bd57f7cab | |||
| 9da87fc789 | |||
| 2139bf25eb | |||
| 083837aa9e | |||
| abba9bcf81 | |||
| 171b621999 | |||
| 52fab81e55 | |||
| 6f4e32fad0 | |||
| 9609f437d5 | |||
| e1a56778f5 | |||
| 23bb0febe9 | |||
| 31397681f5 | |||
| 332bbb8de1 | |||
| 7ccd8ab4ab | |||
| 7af4ecc719 | |||
| ce1c28832e | |||
| 81c913bdd3 | |||
| 5c23e9695f | |||
| c6e96682b6 | |||
| 65da56b2a6 | |||
| a73f9d1ec2 | |||
| 7a02d6a35b | |||
| ff48398430 | |||
| 7ed3c46f23 | |||
| 3acd95741f | |||
| 3f69f29d49 | |||
| fab98cfdea | |||
| 4692d46305 | |||
| 87f9f008e6 | |||
| 50ab23423f | |||
| cd0d940889 | |||
| 777225c252 | |||
| 89b1caadbf | |||
| 4d4060f37b | |||
| 75eea8e2cb | |||
| 3b6af95976 | |||
| 8925318ec4 | |||
| ec330c72be | |||
| 088900aee1 | |||
| 86a2b3fa15 | |||
| afd9850c4b | |||
| 60fe2e07c2 | |||
| c2d94947ee | |||
| 4d9ad4f0af | |||
| bbf53fb28b | |||
| ced27a9974 | |||
| 3378467378 | |||
| 1c2b902de4 | |||
| f7be907633 | |||
| 1e39877af3 | |||
| 6b092026c3 | |||
| 68e835c658 | |||
| e3e709eec6 | |||
| d7508579e5 | |||
| d8d4a60855 | |||
| 196eaac917 | |||
| 91ce540a66 | |||
| dcf43ca9d9 | |||
| 15b0dc51b3 | |||
| 9d8f162f41 | |||
| eec5cbe447 | |||
| b25c09fc53 | |||
| e611c87342 | |||
| a6946f8119 | |||
| 5960a2307e | |||
| 6aaf786ea9 | |||
| 8b8b689187 | |||
| 24d0d4687a | |||
| 0670c2b2bc | |||
| 284178df65 | |||
| 56f83315ed | |||
| 7e2d9bbc4e | |||
| d11af1a463 | |||
| dc4c3ee382 | |||
| 0ef8581764 | |||
| 6c4c0f4821 | |||
| b11479e4e2 | |||
| 3a11ac217e | |||
| d94dbe81dc | |||
| 6462b709f5 | |||
| a86c2c2544 | |||
| 838f291220 | |||
| aeb8fba288 | |||
| 497bfb152e | |||
| 83695b4336 | |||
| e0194f7621 | |||
| 7fd280ea10 | |||
| ca4d566490 | |||
| 7e53698696 | |||
| 54f971f578 | |||
| 18337c6941 | |||
| f56f520308 | |||
| b539e5d63d | |||
| e8b5d286dc | |||
| 882582456e | |||
| e7522be252 | |||
| 5ea342e788 | |||
| 314b2da99f | |||
| 5a3b52dff2 | |||
| 98a0ed0a5b | |||
| 29c3c4009a | |||
| fe550da243 | |||
| a0d88da480 | |||
| ec56fb6b28 | |||
| 9d77bebe3e | |||
| 48858ac28f | |||
| 0e6ea310d1 | |||
| 69c9e3c38c | |||
| 0068341185 | |||
| a8142cd8a0 | |||
| 6e0f604209 | |||
| 34832c7ff7 | |||
| 62f77686c4 | |||
| 5eaec4d0e0 | |||
| 35c5518d1d | |||
| 548356189b | |||
| aa45619244 | |||
| efcf1535ff | |||
| 92b8541654 | |||
| 62d6145c14 | |||
| 58cc638058 | |||
| 7e680f1fee | |||
| a63f264804 | |||
| 99f633e98d | |||
| 0137bfcbf6 | |||
| 33dc5bad03 | |||
| 5d39fc8c5f | |||
| 0921168b91 | |||
| cbba340da6 | |||
| a2b810e34e | |||
| feab4607b5 | |||
| 15cb6ef44f | |||
| f524f365f1 | |||
| 3d8b9d6291 | |||
| 55a9375938 | |||
| 6a6e129c0a | |||
| 6bd2ef5b34 | |||
| 7437240f2f | |||
| 8ad516c5a4 | |||
| eef68706d9 | |||
| 752107ffb0 | |||
| 5193cd899f | |||
| 1563ee014d | |||
| a24079494d | |||
| 44cb928707 | |||
| 4d82cb7883 | |||
| 867cbd582e | |||
| 17badab358 | |||
| ee583af4f9 | |||
| 499678d092 | |||
| 9d9c82c9e9 | |||
| c0c7ad7d0f | |||
| 63645e50b2 | |||
| 891750592d | |||
| b568ef8d8c | |||
| 16706d8338 | |||
| 2df6f73098 | |||
| 7963e52405 | |||
| d0626e670c | |||
| f3f6ea8b2f | |||
| 871a9705e3 | |||
| 5de193d087 | |||
| 60f668deb4 | |||
| 61c06396fc | |||
| 323fe1603e | |||
| a4aedec044 | |||
| 6c88b21b75 | |||
| 6511adc480 | |||
| f6cb26f7f5 | |||
| 6418202118 | |||
| 4b25e855e0 | |||
| a35f6abfd1 | |||
| 716222a671 | |||
| 31801a436c | |||
| f2219a1e06 | |||
| 72fc81b239 | |||
| 43212ad8db | |||
| 0d502a8c55 | |||
| 043cb7f854 | |||
| 8bd5a4e367 | |||
| 43d17a335b | |||
| 84a3fde1ca | |||
| 05d05e671b | |||
| ab6a6654f7 | |||
| dbfbf12862 | |||
| 6166173376 | |||
| 2232d9898e | |||
| 3cf279718f | |||
| 65ec4491e2 | |||
| ce43607c56 | |||
| 150bf5e338 | |||
| 77cbbebfb2 | |||
| 511043a720 | |||
| 19a4b4374d | |||
| 731d5e028a | |||
| 5ea9e48954 | |||
| 73b26e3fbd | |||
| 48be895938 | |||
| 87909d07ec | |||
| 3609eb2b70 | |||
| 562f646fea | |||
| ab3cf5bc5f | |||
| 1b2f07dfa2 | |||
| 2a67c96db3 | |||
| 3fdb789745 | |||
| e4c239e6bc | |||
| 897a35be5d | |||
| d72897dfe8 | |||
| 27723f5055 | |||
| a84e5ebc6a | |||
| 90a8583ad0 | |||
| bf2cef424b | |||
| 6809ebcde9 | |||
| 6fafc533ab | |||
| 060dd647c3 | |||
| 812b4ec8db | |||
| 8c1ddec136 | |||
| 08db5a687c | |||
| ec298b2b90 | |||
| 22f91d51a3 | |||
| d033042ee1 | |||
| 2270f4fe40 | |||
| 6d208b37a5 | |||
| 55ebaef6e3 | |||
| 215f077cf0 | |||
| 4e4f409f87 | |||
| 4d145f4716 | |||
| b833a41a88 | |||
| 768d51c4ae | |||
| f7db298fda | |||
| 4f2118c7ee | |||
| 4f0770b92d | |||
| 1fb8a7a0a5 | |||
| f79ab283f3 | |||
| 23ec691128 | |||
| 59213ebeae | |||
| 36b2f6af2e | |||
| b2249f7756 | |||
| 212023d296 | |||
| 4b03134620 | |||
| 806eea53eb | |||
| 4ca3ee58ac | |||
| 8b003f1187 | |||
| c06a2b2473 | |||
| f2194c6f33 | |||
| b5c294a558 | |||
| c6b6ec048e | |||
| fb461109c1 | |||
| 0411affc88 | |||
| dfe22800dd | |||
| 7868b05ed3 | |||
| 0474f81044 | |||
| ed471a6623 | |||
| 4504973aff | |||
| a5a71edede | |||
| e1c800f3e6 | |||
| 810f86343a | |||
| 5f7d3ac8c1 | |||
| cb5c51cd27 | |||
| 759ccf301c | |||
| 40e4c7e251 | |||
| e12f1784e2 | |||
| 6b8e265f8b | |||
| de33b553be | |||
| ed24a0b89f | |||
| e2697e5a17 | |||
| c4037ccf11 | |||
| 6c6fe134ba | |||
| e3c45f6f27 | |||
| 732258c093 | |||
| 8726fa5d74 | |||
| da61ba96f1 | |||
| 815ce40989 | |||
| 4ff6a62dab | |||
| 918582c967 | |||
| 40c584b121 | |||
| f189dc8c88 | |||
| b291c246f4 | |||
| 59ab7be283 | |||
| 60981386ec | |||
| 436781215f | |||
| 9c4b24475c | |||
| ff8d1fc9ec | |||
| 5f04729ce8 | |||
| 60526f981a | |||
| e39d4972fb | |||
| 233468b37b | |||
| 6eda8bd165 | |||
| 7372e7cbea | |||
| 1fed2201db | |||
| 60b1573386 | |||
| f4695d8395 | |||
| f63c679d3e | |||
| 4e5305c91b | |||
| f30c03a727 | |||
| 354b49d9e5 | |||
| 7b60ee1337 | |||
| ab1d9b246e | |||
| f7b694c9e4 | |||
| be6f6bbfac | |||
| a32f797b0b | |||
| f12abbe038 | |||
| ad2b49928a | |||
| 67f75796fa | |||
| c235ced030 | |||
| d53764fd84 | |||
| 529d8ae3ba | |||
| f864f66e62 | |||
| b1b633bcf9 | |||
| e655e0a882 | |||
| db88fbb694 | |||
| ace3e42281 | |||
| a40000e6b7 | |||
| 21d2d7dfea | |||
| a61731a289 | |||
| c250076032 | |||
| c6d35b103a | |||
| 596c9a5055 | |||
| 9fae4f14d2 | |||
| f1f0b86696 | |||
| e3d2a1fcef | |||
| 2303622475 | |||
| 732277be5e | |||
| 28f205057f | |||
| 9e32ec3e39 | |||
| 1fa86cbb52 | |||
| 9d8a4d4269 | |||
| cb22615bb5 | |||
| 989dc32481 | |||
| 02dd44ad63 | |||
| d6517959d8 | |||
| d9d539c4b8 | |||
| 5b18ffb7ec | |||
| cf70efb6a2 | |||
| a42699e1fb | |||
| 597e82a33b | |||
| e302143b8a | |||
| e99b6af2c5 | |||
| 35a16ac7e0 | |||
| 0d20d9069a | |||
| 8b1d272827 | |||
| 24b3384570 | |||
| 4ca5bfb1ab | |||
| 7c8cf3cb50 | |||
| 6b55d5bb41 | |||
| 5558fc7157 | |||
| 30a7121000 | |||
| fb1568d019 | |||
| a0dca671d8 | |||
| d79870801b | |||
| 2a238a95a9 | |||
| 4bfcf46e36 | |||
| 894316f035 | |||
| 1c47924624 | |||
| 2973b0f200 | |||
| 4fc5751ae1 | |||
| d37ca7eae3 | |||
| 7960f22be9 | |||
| 1b11ec290a | |||
| 751f1d93f3 | |||
| f63a7857a6 | |||
| 017ca24b13 | |||
| 3c22ab7bd1 | |||
| 0bbf64d240 | |||
| af2f20f7b2 | |||
| fef03ddec0 | |||
| f2d0489488 | |||
| f815d5e2fd | |||
| c4a5a3eaf7 | |||
| 921cc6ffa9 | |||
| b582e59eee | |||
| c9f8b83f62 | |||
| 8ff99ce916 | |||
| 27b23a96b6 | |||
| 8ae34223c5 | |||
| 699fc9df1f | |||
| 951d02bfc3 | |||
| 9b9a3b452d | |||
| 02f21a30a8 | |||
| e053664c99 | |||
| 949c6a318f | |||
| f5cb8baf99 | |||
| 025b864bd8 | |||
| b4fcccbe10 | |||
| b9331b5f5a | |||
| 81aa0084e7 | |||
| 58bc6788aa | |||
| 5a767a2d92 | |||
| 282ad43180 | |||
| bcb30ce807 | |||
| 2d865f006e | |||
| b2daebead6 | |||
| 4210091e9a | |||
| 4db09f2240 | |||
| e0260eb551 | |||
| ed1e5474bf | |||
| 65bd7fcc49 | |||
| 80834ccec1 | |||
| 026c39a3de | |||
| 95939dfa02 | |||
| 279da9097c | |||
| 97126332da | |||
| 6641b9a16c | |||
| 927c9afa84 | |||
| d41d7ca0a6 | |||
| ad0c6cfc8d | |||
| 0289f4b524 | |||
| 85b8f5def7 | |||
| f012cb790f | |||
| 05476d7435 | |||
| 583427da05 | |||
| e3a067c27a | |||
| b3ed4cf657 | |||
| 952c81eadc | |||
| cc29ce19ca | |||
| 941aa5f9d8 | |||
| 15e5cc8da1 | |||
| 2cf9205cda | |||
| 2ec89bc57e | |||
| 89294c57d8 | |||
| 624c72fa99 | |||
| 34af580846 | |||
| 910a681f4b | |||
| c4c225343c | |||
| f13a9d0e96 | |||
| c54ae9548f | |||
| 1216607763 | |||
| ecd4d5c338 | |||
| a5fe05cff2 | |||
| 76eafbf48c | |||
| 473ab17fe7 | |||
| bea9bc4ec0 | |||
| 5df1e84fae | |||
| 8665871502 | |||
| ef57f1021c | |||
| b6312f306a | |||
| 70b73868c7 | |||
| 0717b4a290 | |||
| a9b6539910 | |||
| 49520bb8a3 | |||
| 7abe19aec9 | |||
| 3dd0c51be7 | |||
| 565bb87470 | |||
| 9188251501 | |||
| cb11e147ce | |||
| eb1190359d | |||
| cdfc6fd007 | |||
| df9b7d343e | |||
| f26973f46c | |||
| 2335431060 | |||
| 8fd97af0a9 | |||
| 3ea491d379 | |||
| 3bd7d846f4 | |||
| 99344c38a4 | |||
| d917499d1f | |||
| 98da5fecc3 | |||
| 6b0ece5da1 | |||
| 448b149e8e | |||
| 120514125f | |||
| cd4b4365bd | |||
| 8f68801aa9 | |||
| 1d0e8c7e0c | |||
| 3ff43165c2 | |||
| 1fdbdb654a | |||
| 0e024b3b7c | |||
| e1a5e30a75 | |||
| 05d4923db9 | |||
| f18713cd5e | |||
| ef05875bfd | |||
| 59d85a1e16 | |||
| 7eec0d1ed3 | |||
| f917ee189d | |||
| ab2e38b33b | |||
| 38af35e776 | |||
| c6adb87aea | |||
| e8eef1c31e | |||
| bac3abcb4c | |||
| c682bdc01e | |||
| 50cd878f13 | |||
| ea49ba8be2 | |||
| b60056c560 | |||
| 820210dc44 | |||
| 7d998dca3f | |||
| 037d93471d | |||
| 5cb2b871cd | |||
| 44f2c648a8 | |||
| 0ae8a5877e | |||
| 18f6622340 | |||
| 591e79f5a0 | |||
| d898486b49 | |||
| 74e0aee421 | |||
| 07f32e1256 | |||
| ea680cf871 | |||
| e89c75c6cd | |||
| 59d052afd2 | |||
| 9383249ade | |||
| 0a4f30bf02 | |||
| 190f452910 | |||
| 3c59a1af97 | |||
| 11ff628ef8 | |||
| 908e600dc9 | |||
| eb43fde3e4 | |||
| e6ef40e51d | |||
| 7feea5aa6d | |||
| d084cca983 | |||
| d9018868a1 | |||
| 72360457ef | |||
| 0e4c1b71e6 | |||
| 575b761f77 | |||
| 68e950a6bc | |||
| ba5bbebb3e | |||
| cb38896593 | |||
| 21c6a7d87f | |||
| 7c2a569235 | |||
| 1f5b91cbec | |||
| 937f37eff0 | |||
| 4f9f74204a | |||
| ed6735f10f | |||
| 5acd3cf007 | |||
| 279b997bd3 | |||
| 4eb6095822 | |||
| da5b8556f2 | |||
| 261f99ac82 | |||
| 61f3c39cc2 | |||
| 39ab1d0c22 | |||
| 8abb9c3884 | |||
| 58f8ee2ee2 | |||
| 474bcc9544 | |||
| a3f4e25101 | |||
| 8befb664b6 | |||
| 819dd1bcff | |||
| 2b8b853fec | |||
| c536c4a265 | |||
| f13acfe825 | |||
| 8e763ba067 | |||
| 8d7cfd8e46 | |||
| 601058d61c | |||
| f8596ef368 | |||
| 7f0494d52d | |||
| 828478514b | |||
| 146f5437d1 | |||
| c28760f2a8 | |||
| 04f30f6f29 | |||
| caa1d3565b | |||
| 1a7a020bb2 | |||
| 077ab2bb38 | |||
| 6f491bf7d1 | |||
| 9b80c21d0a | |||
| e9dc76a860 | |||
| 9e73324a20 | |||
| 7df93485d8 | |||
| 9018cea5ae | |||
| 32e023231d | |||
| 4766d14359 | |||
| 526b99ec04 | |||
| da132438bd | |||
| 54176ba2db | |||
| 1eca3c2ffd | |||
| 98142f28cd | |||
| 2cf7fc7059 | |||
| a34a18c6cc | |||
| fa738fbadf | |||
| 9ea0516166 | |||
| b760aadb01 | |||
| 24162e14ac | |||
| 9ea495324d | |||
| 437e86a15b | |||
| d9e0b75e9b | |||
| 9606518ba7 | |||
| e2774b830f | |||
| 951d82ad27 | |||
| 4a55cf589c | |||
| b07d80d876 | |||
| ff995b2149 | |||
| 2fb08d59c7 | |||
| 7950c5aa61 | |||
| bf65824429 | |||
| 4013f822de | |||
| b27519fd88 | |||
| 22f97756f7 | |||
| da3f4af171 | |||
| a55d9ae36a | |||
| ecf3a12bd4 | |||
| e7248e2418 | |||
| fba118f0d9 | |||
| 100394d161 | |||
| a9908781be | |||
| 0f050edcd9 | |||
| 2182dfc86b | |||
| 99fa7a57d2 | |||
| 6bf3d10e29 | |||
| ebd2a38e56 | |||
| 03b094e4d4 | |||
| 21b509e5a0 | |||
| 2732a85f9e | |||
| 033141e435 | |||
| 251458a1d7 | |||
| 7c4f406ac6 | |||
| 984c52afc9 | |||
| f664d4ad90 | |||
| 8f61be76f9 | |||
| 8003b9aa1c | |||
| a0fd98b9e2 | |||
| feac31e841 | |||
| dd83d6278c | |||
| 2a6b075ff2 | |||
| e321bc30d0 | |||
| 63fafec1b7 | |||
| 9f48eca5a6 | |||
| 28845b9daf | |||
| 113f41d1d2 | |||
| da3180e290 | |||
| 1a62463678 | |||
| e584cf534d | |||
| 4c1267cd32 | |||
| dc8a3d0c2d | |||
| c481ec850d | |||
| a54dd58de7 | |||
| b13da92520 | |||
| 2b6db85e1a | |||
| e7a1216ef7 | |||
| b1da5c7c2c | |||
| 3b72de34b3 | |||
| af893554cc | |||
| d108ac5d94 | |||
| e446121192 | |||
| afb73b1d17 | |||
| aae8f78cb4 | |||
| 2a1e5c9d1e | |||
| 89a7c4a0f3 | |||
| bdc9de8070 | |||
| de4db8c8a6 | |||
| cb97c127d6 | |||
| 173b5ec2e7 | |||
| ce0c18003b | |||
| 80082639b5 | |||
| 7e885f1be2 | |||
| 9fd506e098 | |||
| 7e6978bc10 | |||
| 4e571e6b10 | |||
| 0127bb04ae | |||
| 0711cfb5f7 | |||
| 8bc361a154 | |||
| 50c6f2b009 | |||
| 190064bfff | |||
| 9189d917d0 | |||
| f55d6606df | |||
| 2615e11e34 | |||
| 7595b9c015 | |||
| dfe9bd94b1 | |||
| 737c4a1104 | |||
| 82024a3250 | |||
| 3447762d91 | |||
| 055034ed67 | |||
| f768254b83 | |||
| 6d25e9687e | |||
| f2af17d359 | |||
| 89ab29ea5f | |||
| f12f3fe007 | |||
| 9c14c86358 | |||
| 8603c67347 | |||
| 74ec8f4fa6 | |||
| 3ddd4449b1 | |||
| 782cd426a4 | |||
| 2744e7a5a0 | |||
| ae28d125f2 | |||
| 05cf150982 | |||
| 6245c4066f | |||
| f7ecc3fdfc | |||
| 292a218a16 | |||
| c095498247 | |||
| 8276692ebf | |||
| 7e369dabdc | |||
| 631ed49ec7 | |||
| 25761215c3 | |||
| b4d4f84161 | |||
| 8e8360a992 | |||
| a1f389cb73 | |||
| 2cc439853f | |||
| 76b2937c18 | |||
| f2a9f4ab33 | |||
| ec375e79d7 | |||
| 338a4d9761 | |||
| 83d457f2b3 | |||
| 3507095572 | |||
| 4e7cf481fd | |||
| 0915bb9402 | |||
| 7c5d1c2959 | |||
| 8aecf1f84b | |||
| 2c45d8dd5b | |||
| fac337eaf1 | |||
| e7d8948334 | |||
| 6b8831872c | |||
| 4e8c373d1b | |||
| 8865dab6b0 | |||
| e4a2bd2f69 | |||
| a132916525 | |||
| a9dcb34b2d | |||
| 74c43355e4 | |||
| 7255e86595 | |||
| e4098a226e | |||
| 5dea5977ad | |||
| 1c9a30773e | |||
| e276944b40 | |||
| 2e14991815 | |||
| 3083727aff | |||
| d778c639dc | |||
| 10de186598 | |||
| 64107fab17 | |||
| 52bfbddcca | |||
| 5d9cc490d7 | |||
| 13cac8db9a | |||
| 3ab5e4d8cc | |||
| 7e728dd5af | |||
| 597d2e3282 | |||
| 57611a3f30 | |||
| ec64c83cb0 | |||
| ecdaaea3b9 | |||
| bda41417aa | |||
| 5a76b5bcdc | |||
| 4edd8eaa7b | |||
| 742a925040 | |||
| bcede7710f | |||
| c02f67e0d1 | |||
| 31650aac96 | |||
| 730f6bab6f | |||
| f923552f86 | |||
| eca1032d16 | |||
| 570372fa83 | |||
| 5ed09ad783 | |||
| c385aa0b8d | |||
| ec152cbd9d | |||
| b36fc35e04 | |||
| 198e77cae9 | |||
| 9c4beb29a5 | |||
| 6accb530c6 | |||
| 1a77ba5fcd | |||
| 7e9dd8b895 | |||
| 78fcacf7aa | |||
| 077f5d588b | |||
| 8b73c67836 | |||
| 92fa05cb06 | |||
| 18f5a33279 | |||
| f9a6e9c4fb | |||
| abfefab545 | |||
| 79f8c520bd | |||
| fa35ed1cb6 | |||
| 2e8d612078 | |||
| 4801b0f323 | |||
| 783c94dadd | |||
| c8cf662ad0 | |||
| cd70e6b836 | |||
| 72cfbf71f8 | |||
| cb36800c75 | |||
| 559c504e8b | |||
| de3a37f40c | |||
| 6020cdf8bf | |||
| 429cb07b79 | |||
| 2cf93c5765 | |||
| db41c8d806 | |||
| 5313369d85 | |||
| c8c17dac01 | |||
| bbb864773f | |||
| 4767fec86e | |||
| 6d57f070f9 | |||
| 97d47d80ee | |||
| 35f59b5f95 | |||
| 697fb06909 | |||
| efd536357c | |||
| 2c917a559c | |||
| b97c1a1b59 | |||
| 9237046b96 | |||
| 646bbceb99 | |||
| e9e164c679 | |||
| 033c6c698a | |||
| 3d403c2471 | |||
| b22e3d2573 | |||
| 7d20c5b732 | |||
| 2ce2337674 | |||
| 3fe26ae4dd | |||
| 6f4faf7a58 | |||
| e1dcfb76f4 | |||
| f658f2c5b7 | |||
| dd7eed834c | |||
| e4f8b22bc6 | |||
| 0b8fa5ea06 | |||
| 140fcae403 | |||
| 95920728f4 | |||
| e85be95d2d | |||
| 3006b3ab3b | |||
| d4d6cfa87d | |||
| b8cfcbe5ee | |||
| 9875833c90 | |||
| 38d94484bb | |||
| 0b3014ff88 | |||
| 04c64949e7 | |||
| be59d50678 | |||
| 04e2497dd3 | |||
| 2e27e85ac5 | |||
| 2c59cb4871 | |||
| 64ddd07171 | |||
| 1b91fbc806 | |||
| 2b6cffc8ef | |||
| 5cc0afef85 | |||
| 52adbb7335 | |||
| dd3bdd2846 | |||
| f088599dec | |||
| fe573865aa | |||
| 5316ed57af | |||
| 1567239ae6 | |||
| 24c65f8942 | |||
| 213e63830d | |||
| efe532e4d0 | |||
| 8392f46db9 | |||
| 87cacc9b20 | |||
| d808893274 | |||
| ab671ac7eb | |||
| 2343e85f4d | |||
| 70a6b847e2 | |||
| a3f6bc2acb | |||
| 1bce95586b | |||
| 80aa557e0c | |||
| 686e26a503 | |||
| a33cdae4c3 | |||
| 258f665338 | |||
| 3b70829d72 | |||
| 524f60ab48 | |||
| fdc58ce450 | |||
| a4595b427d | |||
| 522e33be12 | |||
| 146a79b516 | |||
| 4f85cf1723 | |||
| d35799e2ce | |||
| a003e2e979 | |||
| f4b8e85689 | |||
| 6b94097f29 | |||
| 6e1dbf3a8e | |||
| 0dc56aad1c | |||
| a565853c5e | |||
| ac56ee1553 | |||
| 349914f447 | |||
| 2a1bddf5e4 | |||
| 668dad9c6f | |||
| 2b978be79c | |||
| 66917b6db0 | |||
| 292745866d | |||
| f86fabafbe | |||
| 48a624bd07 | |||
| 66c2e779ea | |||
| f84dcb64d3 | |||
| 95bb974ca6 | |||
| 953ef0e5bc | |||
| 1b2024e456 | |||
| e961c3a9ed | |||
| 22d50208d8 | |||
| b43cc72de2 | |||
| a06691b214 | |||
| 3461ee6a72 | |||
| 8662db67b8 | |||
| 321a7810c4 | |||
| eae7bba649 | |||
| 92c572d761 | |||
| 868ebf2025 | |||
| 9f9182c564 | |||
| c62774f1a6 | |||
| eace9b4ef6 | |||
| bc4610af04 | |||
| 729fa8eb46 | |||
| 8ca78e21b6 | |||
| b17454723e | |||
| 5e8aa8818f | |||
| ffcfd019c2 | |||
| 7298d9dfdc | |||
| be3b135cc7 | |||
| 9848f8b92c | |||
| 59eb7376c9 | |||
| ea017467fd | |||
| 2c0a2e694b | |||
| 993354bce5 | |||
| 8299b68b96 | |||
| bf9f9e1064 | |||
| 5cf8a7a8a4 | |||
| da91df5754 | |||
| 341b69ed75 | |||
| a7a3ce4ea1 | |||
| f83d03fb16 | |||
| 34e1935a97 | |||
| 0080b028bf | |||
| 689d84fa78 | |||
| 64c9759de8 | |||
| 31cac3eef3 | |||
| 4e670a8cbe | |||
| bbfcc9d7d8 | |||
| 29cc98a7f5 | |||
| 8e54d2e253 | |||
| dd69204f5a | |||
| 44a102c3b1 | |||
| f487853954 | |||
| a29d9cf4ff | |||
| 3fa6ed74e5 | |||
| d3c1c2be6c | |||
| f274fe1cf6 | |||
| f358eab214 | |||
| 59d76148dc | |||
| 489e520ddd | |||
| 60ecb03f64 | |||
| 8a99e67c6d | |||
| 482a52cb5e | |||
| ba13c5cae1 | |||
| 4b57be3917 | |||
| 9383e5eed2 | |||
| a3b4a5e30e | |||
| 72a45d7d80 | |||
| bcf464428a | |||
| f3b9f4bf73 | |||
| 10e54ed789 | |||
| 35da8df526 | |||
| fb1ab220ff | |||
| 2dd39fddf0 | |||
| 7f69e9f329 | |||
| 3f6a4237ad | |||
| ee04e8c17f | |||
| 7d75c15027 | |||
| 312a44d361 | |||
| 85d38e3db6 | |||
| 3a25ee2c93 | |||
| a4d49a41e0 | |||
| 7ba9e10f0f | |||
| 05e966011e | |||
| 9081f6bce4 | |||
| c126e8b615 | |||
| f454803ef7 | |||
| 40beb8f752 | |||
| 4d8d332732 | |||
| 7fb771b992 | |||
| d0900a95a7 | |||
| 8552d463a1 | |||
| 74d130644c | |||
| 976e0dd2b7 | |||
| 340c25ba0b | |||
| 7e8d4bc9a8 | |||
| 429544373a | |||
| 80dd6fa9e1 | |||
| 45ac120407 | |||
| 2c100ca1e5 | |||
| c54bd9e1ce | |||
| a2a35e481a | |||
| 84ff0c777d | |||
| 37ecd57a9b | |||
| 8578a9bd01 | |||
| 6b64f38fa3 | |||
| ea9206f56b | |||
| 467c0989e1 | |||
| 2a0d44acc5 | |||
| a9b28b54d5 | |||
| c296a5d4a4 | |||
| 10926a1240 | |||
| 992e962df7 | |||
| 7726925771 | |||
| a53b0e9837 | |||
| 26eb2d4e54 | |||
| b53b27cf2d | |||
| cecda22ec3 | |||
| dc5fe62e3a | |||
| c957989abb | |||
| 708fec6886 | |||
| 32db2355a2 | |||
| c1d4e8e482 | |||
| a00c58e521 | |||
| 698b56afcf | |||
| af285c5ffe | |||
| 37917c497e | |||
| 50ec2551f8 | |||
| 4519c88230 | |||
| d84724b8b0 | |||
| 56d21bdf59 | |||
| 260c1612a6 | |||
| 6ab3106b38 | |||
| c79d442158 | |||
| 7a6de144ce | |||
| 5240999f56 | |||
| 0a94e60e22 | |||
| c83fdab502 | |||
| ca0c2fd9e6 | |||
| a0c842acb6 | |||
| ba17246755 | |||
| af766449d2 | |||
| 30052b4d74 | |||
| 9f02b6edb0 | |||
| 22e24e6e6c | |||
| 48bc1995bb | |||
| 854e289bba | |||
| db9d55a5cc | |||
| cca0efbd8d | |||
| 596446d14b | |||
| 578bc7cd5a | |||
| d58eb52944 | |||
| 906d8322e3 | |||
| c2be26adb2 | |||
| cf88823e6f | |||
| 2fbee75453 | |||
| 07edcc4867 | |||
| 65d7934c21 | |||
| 842d98dc1c | |||
| b7e69ddc61 | |||
| 2dc6041bd7 | |||
| b007646d4b | |||
| 5580f3dc81 | |||
| 82f7905367 | |||
| 1d8699054c | |||
| 32c521cb79 | |||
| b4cf8cd451 | |||
| 80ff9d0f66 | |||
| b0e60e60e4 | |||
| c4b9a76931 | |||
| fe52f0ad10 | |||
| a9abf9a1af | |||
| 815f9605f9 | |||
| 9a9d6fc0bb | |||
| 2f691bf1b8 | |||
| 50984dab14 | |||
| 6f6ce4bcc7 | |||
| 119729393c | |||
| 9f3869e878 | |||
| 9fb2a73ec5 | |||
| 64b3699b3c | |||
| 76ad31a3bc | |||
| 71cdee5a4d | |||
| 2ae4b23528 | |||
| 39927ac6c0 | |||
| 3e6e59db29 | |||
| 36e2c6f66f | |||
| 69d56f4632 | |||
| af0f731a8a | |||
| cf8c05e1c5 | |||
| 7d5e307368 | |||
| 701b28c33c | |||
| a239ca439a | |||
| 578af19baa | |||
| 792ed007b5 | |||
| 539c2338fc | |||
| 792694b2d9 | |||
| 8e20d56091 | |||
| 1986142db3 | |||
| c52df5dc36 | |||
| 617d44ed75 | |||
| 91e6a73f33 | |||
| 25d7087d07 | |||
| f72267e81d | |||
| ab3b0f3c3c | |||
| 883c4dcf19 | |||
| a5aa73dea6 | |||
| ed90c2667a | |||
| 87d9477bc7 | |||
| b854119445 | |||
| 0e56ab131e | |||
| e319417fbc | |||
| 9e831689e9 | |||
| 0a5f4e6551 | |||
| aaf158cc29 | |||
| 2c2dd37275 | |||
| 4d4a3b6bf6 | |||
| b6b1d72ecb | |||
| 6fa44ce5e9 | |||
| 90e7a303ab | |||
| 54256be459 | |||
| 1c662c55cc | |||
| abd1adaabf | |||
| 5411de90fc | |||
| f9a692b5ef | |||
| 9205ef8024 | |||
| 4260afaa7e | |||
| ef3a60397f | |||
| 8acc51116d | |||
| cbbc5e8500 | |||
| 0192fb8308 | |||
| 3841528f5a | |||
| 91c3825ae3 | |||
| 8c26dd8382 | |||
| 01b317484f | |||
| 73a6ad2cf2 | |||
| 574312d7c5 | |||
| 6cb8e007aa | |||
| 22f6a12842 | |||
| c15508150a | |||
| a0f12a2c48 | |||
| c919a1762b | |||
| 6dc73bf710 | |||
| 623b802d56 | |||
| 0726289c7a | |||
| d2edf12fdf | |||
| 9694fb901a | |||
| a8982cf8c7 | |||
| f430ed7169 | |||
| 4f5a501be4 | |||
| 6c312efc9a | |||
| 1b987be562 | |||
| c84536fef7 | |||
| 1044298d76 | |||
| 4e971932d1 | |||
| 4834e2297a | |||
| 2a3f70eb4a | |||
| ea633ce3f9 | |||
| f6b64126cf | |||
| 9d3c15f284 | |||
| 7d224ec5ac | |||
| ed4e34b808 | |||
| f5c008c1a7 | |||
| dc71f74c0c | |||
| d5470de8fd | |||
| dff5903c53 | |||
| fc241b1cdc | |||
| 77ba732eec | |||
| 835175aa36 | |||
| 2e2827717d | |||
| 209f85c17e | |||
| 37c373c51f | |||
| 62fe03e8c1 | |||
| 427c28db7a | |||
| 835b363661 | |||
| df67ed57ee | |||
| 43b3cc2ca4 | |||
| 3c2268870b | |||
| fbb1267609 | |||
| 2c443a3b93 | |||
| 13fd8db0b7 | |||
| cdee0df5ab | |||
| 9e418afe64 | |||
| 7d43eb5d2e | |||
| de4c16431d | |||
| d3e6860b1c | |||
| 6bccf5595b | |||
| 35023efbf2 | |||
| d33460e3bd | |||
| eea059c0d3 | |||
| 2a327cc29e | |||
| 1ac1bf5b60 | |||
| ad5cace75b | |||
| bf49843721 | |||
| 25d9e3b1ca | |||
| dc07b2bdf4 | |||
| 0093acb578 | |||
| b89ecf4c03 | |||
| 468412100c | |||
| ea7e4b277f | |||
| 60e35c1bb9 | |||
| 117bb5bd86 | |||
| e8ba274776 | |||
| 76a1e20f13 | |||
| 8cab2fdcb6 | |||
| 354fcdc84b | |||
| 99e26a5805 | |||
| d354d6e788 | |||
| 28bcf479f3 | |||
| e3f8fc0e01 | |||
| e8184f0248 | |||
| 937de0fa00 | |||
| ac24bc86a0 | |||
| 1338a43c03 | |||
| 8889105d5a | |||
| 9cbe6b73fc | |||
| ff98fe38c2 | |||
| 9899c15d36 | |||
| 601b29c28b | |||
| 76e16b365d | |||
| 1021e8bc00 | |||
| 4f740fc9f8 | |||
| 75fc5c6e1e | |||
| 47cf63e0e6 | |||
| b4a1aacd12 | |||
| ad499b977e | |||
| b5c55f4e65 | |||
| 65b69829d7 | |||
| cf6eb604bd | |||
| 8655f5903a | |||
| 45f1dddb81 | |||
| 299d20aac9 | |||
| 43d16474c2 | |||
| ee08458df1 | |||
| c80958a776 | |||
| 13d8a8420a | |||
| 01a58ad2ed | |||
| a4e66e708a | |||
| 66e0698d2f | |||
| 935694cb64 | |||
| e2404f919e | |||
| c9810dd9eb | |||
| 6bfd3eada4 | |||
| 6852bae7f9 | |||
| 8536bdd614 | |||
| bd13c73f2f | |||
| 2a9ab569b4 | |||
| d6ebce0425 | |||
| 3af306abe0 | |||
| 30563f3648 | |||
| d6a2e7a9f7 | |||
| 32d686e908 | |||
| 05f906427e | |||
| d8653961af | |||
| d521bbc0fa | |||
| 281f7203dc | |||
| dd683af5f5 | |||
| 9a5506d901 | |||
| 5fc2907392 | |||
| 1443082991 | |||
| d4e3956941 | |||
| e3a457f84c | |||
| e40cd9f6a2 | |||
| eef498d47a | |||
| 8d4a9dc231 | |||
| e0d3c940f8 | |||
| be6d395ed6 | |||
| 87aa0b6659 | |||
| bb167b14ef | |||
| 351866d9e4 | |||
| 9a8f8433b0 | |||
| 4942789213 | |||
| 0741265837 | |||
| 06d4e1703e | |||
| 41be2a7b78 | |||
| 610d12283d | |||
| fee8da1613 | |||
| 28bed96e40 | |||
| 050800f5f7 | |||
| 21fe94b38c | |||
| ce639c12d8 | |||
| 78dd4e0086 | |||
| 0f7eebd683 | |||
| 860b635188 | |||
| 0710b4e8a1 | |||
| 823abc121e | |||
| 3fa6128561 | |||
| ca00e53a40 | |||
| 0003d2efd3 | |||
| 0efe9f05f2 | |||
| 88d0c5feb3 | |||
| 912aa38063 | |||
| 5fba658c66 | |||
| 070601689a | |||
| bde177fc34 | |||
| a593f71901 | |||
| 107fc501e4 | |||
| cd51fb85cf | |||
| 9591a05361 | |||
| ddfffaf6a2 | |||
| baffe1b79e | |||
| 145eb8f611 | |||
| a279835cf8 | |||
| 2dc04a8517 | |||
| 5c076933e7 | |||
| 417c2e4d1e | |||
| cbfb4d6d32 | |||
| 99ac768778 | |||
| 7177d0c37e | |||
| ff257fcd77 | |||
| 47243334f4 | |||
| 1693b643a7 | |||
| 9790dff27e | |||
| ab1d65e6f0 | |||
| 5bbadbbdc8 | |||
| ce92cd31bf | |||
| 8689d0e8b0 | |||
| f47e548b04 | |||
| 6fef2a9a87 | |||
| bc3ceab039 | |||
| b9a0e6cbb6 | |||
| c50fd4b3ac | |||
| 430f7b7217 | |||
| 72a3cea948 | |||
| fce22b08e9 | |||
| a2e64b4e0b | |||
| 1df87447bd | |||
| 75b2b3b163 | |||
| 80d90f93cd | |||
| e1ac4233c7 | |||
| 46c3bbff3c | |||
| 41b8292f25 | |||
| 366b95c8e8 | |||
| fecf068455 | |||
| 1da1133934 | |||
| c4ac84c1a1 | |||
| 2cf9dcafd9 | |||
| 784abcba4e | |||
| aaa44fb7aa | |||
| f7a4a23045 | |||
| 7e3c892ff6 | |||
| 36a654bcfe | |||
| e16182ee6a | |||
| 7c46bf4b9e | |||
| 7c82580b4b | |||
| 1e1e9b03c0 | |||
| 0587145145 | |||
| 7840da94b5 | |||
| 010866e0d0 | |||
| c54b057d90 | |||
| b55f3a9c4d | |||
| aa09e738e6 | |||
| 4254b85628 | |||
| 7d5e946067 | |||
| 9eda525d2a | |||
| 8ef337f40b | |||
| f5ac584ed5 | |||
| a3534d802a | |||
| 92b689255b | |||
| fb5167963a | |||
| 50ac4b6381 | |||
| d842fc73cb | |||
| 531d118ed0 | |||
| cead705c21 | |||
| e5a2afee37 | |||
| f2efb235eb | |||
| ffc1a5ad8f | |||
| 1c3764b099 | |||
| 5af045844e | |||
| be255ec7af | |||
| 7f7dec4e80 | |||
| 8a6687d00c | |||
| 1b719027e6 | |||
| d661f7b798 | |||
| e437869c13 | |||
| c979de9387 | |||
| be806949bf | |||
| 1c08725ade | |||
| bb939bc4cd | |||
| c88b28606e | |||
| 172dc91ec1 | |||
| 3a46bb4920 | |||
| aba2e6b140 | |||
| d678cdfff4 | |||
| 218752bb40 | |||
| 17b711d097 | |||
| 346090f7dc | |||
| 20dd6f8383 | |||
| c31e0a50b5 | |||
| c2172aa562 | |||
| 9174186442 | |||
| 8ef82abe9d | |||
| 9e58b6572e | |||
| 311e443d21 | |||
| 6a8fceff5b | |||
| 6ceb7f735c | |||
| 5c8f2034c3 | |||
| f8e429f08a | |||
| e84c793ba6 | |||
| 0812c9a3bc | |||
| 0d0b043bb8 | |||
| 16d3458e5a | |||
| f775e40b16 | |||
| cf847d3b8e | |||
| 53489e7356 | |||
| c028e1befc | |||
| 790bb04ae5 | |||
| 165f286bfd | |||
| 05dfe8c4a3 | |||
| ea37f05c11 | |||
| 379f428961 | |||
| 88ac3051f3 | |||
| 99f4fc8339 | |||
| 2480578bd9 | |||
| 5ae143c98e | |||
| 1473956a8a | |||
| 01426308c5 | |||
| a090d6de32 | |||
| e9ddd0caa8 | |||
| a258c59ca3 | |||
| 8021fcc24c | |||
| 55f7cbb1bb | |||
| dad0ccb3c0 | |||
| 06f1bcfb3f | |||
| 2e20ae2148 | |||
| 09676f8314 | |||
| 75b6e4f633 | |||
| 1bebdcba89 | |||
| c589f34986 | |||
| e970dadb6f | |||
| 0c0f7905da | |||
| af8bb6aa4d | |||
| ca132a6d18 | |||
| f519ea0193 | |||
| 1ae4a63d4e | |||
| 5c4db8df5b | |||
| 85eca1a75e | |||
| c3a21388f4 | |||
| 082ef79346 | |||
| 85dc424ea0 | |||
| b2e183e363 | |||
| e548836d38 | |||
| 4a2bb3d7fc | |||
| 65e0ebdb37 | |||
| d3d02f173a | |||
| c39d24ccdc | |||
| 1994ce38eb | |||
| 9aad6de823 | |||
| 3d3afdb645 | |||
| 983f5001ab | |||
| a80fdf0990 | |||
| 82d7e78455 | |||
| d514b929b3 | |||
| 720210ac08 | |||
| 2dfc05db5f | |||
| d551934ec1 | |||
| bac1e30cf0 | |||
| 8fdb2c4e57 | |||
| 8da1fb78b8 | |||
| cea8163366 | |||
| 388e4f8601 | |||
| 2756873c53 | |||
| a770e1f67e | |||
| f8c844c4c0 | |||
| 7f23d4cf68 | |||
| 247c75191b | |||
| 4f3e1b4fe6 | |||
| 6291e92ed7 | |||
| 5054afcbb5 | |||
| 980e0d6ef7 | |||
| 2f6147f325 | |||
| 56fb88b75e | |||
| 24bdda8ca1 | |||
| c38e46fc2a | |||
| 916cc3746d | |||
| a32bc2985a | |||
| 8d982b4615 | |||
| 10e77707d0 | |||
| b0fe208768 | |||
| b44d6d2d90 | |||
| 828047e272 | |||
| a9cb1bf518 | |||
| d71f421981 | |||
| 26e947992e | |||
| 78e4804774 | |||
| 5ccd1bc2fe | |||
| f758884c75 | |||
| 9d2d34a25c | |||
| fc23461445 | |||
| 5253504df9 | |||
| dd270b862e | |||
| 5bc1362493 | |||
| 96a0c923c2 | |||
| 23bb2871fd | |||
| d4ea5f8b38 | |||
| 4b2cdc3d39 | |||
| 4c54d9c9ea | |||
| 9541d5eceb | |||
| c9c1023ece | |||
| cb2073eb8b | |||
| d35104aea6 | |||
| ad342f2ca4 | |||
| 29541ff520 | |||
| 6a1c160608 | |||
| 731c802fcd | |||
| b6f15934f2 | |||
| 068449c59c | |||
| 4f36a2c7c1 | |||
| bb04231880 | |||
| 1ef790ce31 | |||
| 65490f3cf4 | |||
| ec43b5c822 | |||
| 81531235bc | |||
| 66683151ec | |||
| e751d140f2 | |||
| 0f8009b1e9 | |||
| 01e153662e | |||
| 08dd5b5b15 | |||
| c9ffd23729 | |||
| ccd2eaec70 | |||
| 79cdc2e952 |
@@ -1,8 +0,0 @@
|
|||||||
engines:
|
|
||||||
sonar-python:
|
|
||||||
enabled: true
|
|
||||||
checks:
|
|
||||||
python:S107:
|
|
||||||
enabled: false
|
|
||||||
exclude_patterns:
|
|
||||||
- "alembic/"
|
|
||||||
+5
-1
@@ -1,4 +1,8 @@
|
|||||||
.editorconfig
|
.editorconfig
|
||||||
.codeclimate.yml
|
|
||||||
*.png
|
*.png
|
||||||
*.md
|
*.md
|
||||||
|
logs
|
||||||
|
start
|
||||||
|
config.yaml
|
||||||
|
registration.yaml
|
||||||
|
*.db
|
||||||
|
|||||||
+9
-4
@@ -8,8 +8,13 @@ charset = utf-8
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[*.py]
|
[*.md]
|
||||||
max_line_length = 99
|
trim_trailing_whitespace = false
|
||||||
|
indent_size = 2
|
||||||
[*.{yaml,yml,py}]
|
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
|
||||||
|
[*.{yaml,yml,sql}]
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
[{.gitlab-ci.yml,.pre-commit-config.yaml,provisioning-spec.yaml,.github/workflows/*.yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pkg/connector/humanise/errors.go linguist-generated=true
|
||||||
|
pkg/gotd/tg/* linguist-generated=true
|
||||||
|
tl_*_gen.go linguist-generated=true
|
||||||
|
*.gen.go linguist-generated=true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: If something is definitely wrong in the bridge (rather than just a setup issue),
|
||||||
|
file a bug report. Remember to include relevant logs. Asking in the Matrix room first
|
||||||
|
is strongly recommended.
|
||||||
|
type: Bug
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Include relevant logs, the bridge version and other important details here -->
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
<!-- All items below are mandatory. Issues not following the rules may be closed without comment. -->
|
||||||
|
|
||||||
|
* [ ] This is an actual bug, not just a setup issue (see the [troubleshooting docs](https://docs.mau.fi/bridges/general/troubleshooting.html) or ask in the Matrix room for setup help).
|
||||||
|
* [ ] I am certain that sufficient information is included. Ask in the Matrix room first if not.
|
||||||
|
* [ ] The bug is still present on the main branch.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
contact_links:
|
||||||
|
- name: Troubleshooting docs & FAQ
|
||||||
|
url: https://docs.mau.fi/bridges/general/troubleshooting.html
|
||||||
|
about: Check this first if you're having problems setting up the bridge.
|
||||||
|
- name: Support room
|
||||||
|
url: https://matrix.to/#/#telegram:maunium.net
|
||||||
|
about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
name: Enhancement request
|
||||||
|
about: Submit a feature request or other suggestion
|
||||||
|
type: Feature
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: Go
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
go-version: ["1.25", "1.26"]
|
||||||
|
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go-version }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Install libolm
|
||||||
|
run: sudo apt-get install libolm-dev libolm3
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
go install golang.org/x/tools/cmd/goimports@latest
|
||||||
|
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||||
|
export PATH="$HOME/go/bin:$PATH"
|
||||||
|
|
||||||
|
- name: Install pre-commit
|
||||||
|
run: pip install pre-commit
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pre-commit run -a
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: 'Lock old issues'
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 20 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
# pull-requests: write
|
||||||
|
# discussions: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: lock-threads
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lock-stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: dessant/lock-threads@v6
|
||||||
|
id: lock
|
||||||
|
with:
|
||||||
|
issue-inactive-days: 90
|
||||||
|
process-only: issues
|
||||||
|
- name: Log processed threads
|
||||||
|
run: |
|
||||||
|
if [ '${{ steps.lock.outputs.issues }}' ]; then
|
||||||
|
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
|
||||||
|
fi
|
||||||
+13
-8
@@ -1,11 +1,16 @@
|
|||||||
.idea/
|
.idea
|
||||||
|
|
||||||
.venv
|
*.yaml
|
||||||
pip-selfcheck.json
|
!.pre-commit-config.yaml
|
||||||
*.pyc
|
!example-config.yaml
|
||||||
__pycache__
|
!provisioning-spec.yaml
|
||||||
|
|
||||||
config.yaml
|
*.json
|
||||||
registration.yaml
|
!pkg/connector/emojis/unicodemojipack.json
|
||||||
|
*.db*
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.bak
|
||||||
|
|
||||||
|
/mautrix-telegram
|
||||||
|
/mautrix-telegramgo
|
||||||
|
/start
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
include:
|
||||||
|
- project: 'mautrix/ci'
|
||||||
|
file: '/gov2-as-default.yml'
|
||||||
Generated
+1
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Livello_1" data-name="Livello 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 240 240"><defs><linearGradient id="linear-gradient" x1="120" y1="240" x2="120" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d93d2"/><stop offset="1" stop-color="#38b0e3"/></linearGradient></defs><title>Telegram_logo</title><circle cx="120" cy="120" r="120" fill="url(#linear-gradient)"/><path d="M81.229,128.772l14.237,39.406s1.78,3.687,3.686,3.687,30.255-29.492,30.255-29.492l31.525-60.89L81.737,118.6Z" fill="#c8daea"/><path d="M100.106,138.878l-2.733,29.046s-1.144,8.9,7.754,0,17.415-15.763,17.415-15.763" fill="#a9c6d8"/><path d="M81.486,130.178,52.2,120.636s-3.5-1.42-2.373-4.64c.232-.664.7-1.229,2.1-2.2,6.489-4.523,120.106-45.36,120.106-45.36s3.208-1.081,5.1-.362a2.766,2.766,0,0,1,1.885,2.055,9.357,9.357,0,0,1,.254,2.585c-.009.752-.1,1.449-.169,2.542-.692,11.165-21.4,94.493-21.4,94.493s-1.239,4.876-5.678,5.043A8.13,8.13,0,0,1,146.1,172.5c-8.711-7.493-38.819-27.727-45.472-32.177a1.27,1.27,0,0,1-.546-.9c-.093-.469.417-1.05.417-1.05s52.426-46.6,53.821-51.492c.108-.379-.3-.566-.848-.4-3.482,1.281-63.844,39.4-70.506,43.607A3.21,3.21,0,0,1,81.486,130.178Z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
exclude: pkg/gotd/_fuzz/.*|pkg/gotd/_schema/.*|pkg/gotd/.*\.tmpl
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v6.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
exclude_types: [markdown]
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
|
||||||
|
- repo: https://github.com/tekwizely/pre-commit-golang
|
||||||
|
rev: v1.0.0-rc.4
|
||||||
|
hooks:
|
||||||
|
- id: go-imports
|
||||||
|
args:
|
||||||
|
- "-local"
|
||||||
|
- "go.mau.fi/mautrix-telegram"
|
||||||
|
- "-w"
|
||||||
|
- id: go-vet-mod
|
||||||
|
# Disabled for now until we can find a way to filter out the gotd package
|
||||||
|
# - id: go-staticcheck-repo-mod
|
||||||
|
- id: go-mod-tidy
|
||||||
|
|
||||||
|
- repo: https://github.com/beeper/pre-commit-go
|
||||||
|
rev: v0.4.2
|
||||||
|
hooks:
|
||||||
|
- id: prevent-literal-http-methods
|
||||||
|
- id: zerolog-ban-global-log
|
||||||
|
- id: zerolog-ban-msgf
|
||||||
|
- id: zerolog-use-stringer
|
||||||
+1078
File diff suppressed because it is too large
Load Diff
+14
-23
@@ -1,29 +1,20 @@
|
|||||||
FROM docker.io/alpine:3.8
|
FROM golang:1-alpine3.23 AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
|
||||||
|
|
||||||
|
COPY . /build
|
||||||
|
WORKDIR /build
|
||||||
|
RUN ./build.sh
|
||||||
|
|
||||||
|
FROM alpine:3.23
|
||||||
|
|
||||||
ENV UID=1337 \
|
ENV UID=1337 \
|
||||||
GID=1337 \
|
GID=1337
|
||||||
FFMPEG_BINARY=/usr/bin/ffmpeg
|
|
||||||
|
|
||||||
COPY . /opt/mautrix-telegram
|
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
|
||||||
WORKDIR /opt/mautrix-telegram
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
python3-dev \
|
|
||||||
build-base \
|
|
||||||
py3-virtualenv \
|
|
||||||
py3-pillow \
|
|
||||||
py3-aiohttp \
|
|
||||||
py3-lxml \
|
|
||||||
py3-magic \
|
|
||||||
py3-numpy \
|
|
||||||
py3-asn1crypto \
|
|
||||||
py3-sqlalchemy \
|
|
||||||
py3-markdown \
|
|
||||||
py3-psycopg2 \
|
|
||||||
ffmpeg \
|
|
||||||
ca-certificates \
|
|
||||||
su-exec \
|
|
||||||
&& pip3 install -r requirements.txt -r optional-requirements.txt
|
|
||||||
|
|
||||||
|
COPY --from=builder /build/mautrix-telegram /usr/bin/mautrix-telegram
|
||||||
|
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|
||||||
CMD ["/opt/mautrix-telegram/docker-run.sh"]
|
CMD ["/docker-run.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ARG DOCKER_HUB="docker.io"
|
||||||
|
|
||||||
|
FROM ${DOCKER_HUB}/alpine:3.23
|
||||||
|
|
||||||
|
ENV UID=1337 \
|
||||||
|
GID=1337
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottieconverter
|
||||||
|
|
||||||
|
ARG EXECUTABLE=./mautrix-telegram
|
||||||
|
COPY $EXECUTABLE /usr/bin/mautrix-telegram
|
||||||
|
COPY ./docker-run.sh /docker-run.sh
|
||||||
|
ENV BRIDGEV2=1
|
||||||
|
VOLUME /data
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
CMD ["/docker-run.sh"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
The mautrix-telegram developers grant the following special exceptions:
|
||||||
|
|
||||||
|
* to Beeper the right to embed the program in the Beeper clients and servers,
|
||||||
|
and use and distribute the collective work without applying the license to
|
||||||
|
the whole.
|
||||||
|
* to Element the right to distribute compiled binaries of the program as a part
|
||||||
|
of the Element Server Suite and other server bundles without applying the
|
||||||
|
license.
|
||||||
|
|
||||||
|
All exceptions are only valid under the condition that any modifications to
|
||||||
|
the source code of mautrix-telegram remain publicly available under the terms
|
||||||
|
of the GNU AGPL version 3 or later.
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
# mautrix-telegram
|
# mautrix-telegram
|
||||||
A Matrix-Telegram hybrid puppeting/relaybot bridge.
|

|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/mautrix/telegram/releases)
|
||||||
|
[](https://mau.dev/mautrix/telegram/container_registry)
|
||||||
|
|
||||||
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
|
A Matrix-Telegram puppeting/relaybot bridge.
|
||||||
|
|
||||||
### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
|
## Sponsors
|
||||||
|
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
All setup and usage instructions are located on
|
||||||
|
[docs.mau.fi](https://docs.mau.fi/bridges/go/telegram/index.html).
|
||||||
|
Some quick links:
|
||||||
|
|
||||||
|
* [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=telegram)
|
||||||
|
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=telegram))
|
||||||
|
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/telegram/authentication.html)
|
||||||
|
|
||||||
|
### Features & Roadmap
|
||||||
|
[ROADMAP.md](ROADMAP.md) contains a general overview of what is supported by the bridge.
|
||||||
|
|
||||||
## Discussion
|
## Discussion
|
||||||
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
||||||
|
|
||||||
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
|
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
|
||||||
|
|
||||||
## Preview
|
|
||||||

|
|
||||||
|
|||||||
+29
-24
@@ -3,34 +3,37 @@
|
|||||||
* Matrix → Telegram
|
* Matrix → Telegram
|
||||||
* [x] Message content (text, formatting, files, etc..)
|
* [x] Message content (text, formatting, files, etc..)
|
||||||
* [x] Message redactions
|
* [x] Message redactions
|
||||||
|
* [x] Message reactions
|
||||||
|
* [x] Message edits
|
||||||
* [ ] ‡ Message history
|
* [ ] ‡ Message history
|
||||||
* [ ] † Presence
|
* [ ] Presence
|
||||||
* [ ] † Typing notifications
|
* [x] Typing notifications
|
||||||
* [ ] † Read receipts
|
* [x] Read receipts
|
||||||
* [x] Pinning messages
|
* [ ] Pinning messages
|
||||||
* [x] Power level
|
* [ ] Power level
|
||||||
* [x] Normal chats
|
* [ ] Membership actions (invite/kick/join/leave)
|
||||||
* [ ] Non-hardcoded PL requirements
|
* [ ] Room metadata changes (name, topic, avatar)
|
||||||
* [x] Supergroups/channels
|
* [ ] Initial room metadata
|
||||||
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
|
|
||||||
* [x] Membership actions (invite/kick/join/leave)
|
|
||||||
* [x] Room metadata changes (name, topic, avatar)
|
|
||||||
* [x] Initial room metadata
|
|
||||||
* [ ] User metadata
|
|
||||||
* [ ] Initial displayname/username/avatar at register
|
|
||||||
* [ ] ‡ Changes to displayname/avatar
|
|
||||||
* Telegram → Matrix
|
* Telegram → Matrix
|
||||||
* [x] Message content (text, formatting, files, etc..)
|
* [x] Message content (text, formatting, files, etc..)
|
||||||
|
* [ ] Advanced message content/media
|
||||||
|
* [x] Custom emojis
|
||||||
|
* [ ] Polls
|
||||||
|
* [ ] Games
|
||||||
|
* [ ] Buttons
|
||||||
* [x] Message deletions
|
* [x] Message deletions
|
||||||
|
* [x] Message reactions
|
||||||
* [x] Message edits
|
* [x] Message edits
|
||||||
* [ ] Message history
|
* [x] Message history
|
||||||
|
* [x] Automatically when creating portal
|
||||||
|
* [x] Automatically for missed messages
|
||||||
* [x] Avatars
|
* [x] Avatars
|
||||||
* [x] Presence
|
* [ ] Presence
|
||||||
* [x] Typing notifications
|
* [x] Typing notifications
|
||||||
* [x] Read receipts (private chat only)
|
* [x] Read receipts (DMs only)
|
||||||
* [x] Pinning messages
|
* [ ] Pinning messages
|
||||||
* [x] Admin/chat creator status
|
* [x] Admin/chat creator status
|
||||||
* [ ] Supergroup/channel permissions (precise per-user permissions not supported in Matrix)
|
* [x] Supergroup/channel permissions (precise per-user permissions not supported in Matrix)
|
||||||
* [x] Membership actions (invite/kick/join/leave)
|
* [x] Membership actions (invite/kick/join/leave)
|
||||||
* [ ] Chat metadata changes
|
* [ ] Chat metadata changes
|
||||||
* [x] Title
|
* [x] Title
|
||||||
@@ -40,14 +43,16 @@
|
|||||||
* [x] Initial chat metadata (about text missing)
|
* [x] Initial chat metadata (about text missing)
|
||||||
* [x] User metadata (displayname/avatar)
|
* [x] User metadata (displayname/avatar)
|
||||||
* [x] Supergroup upgrade
|
* [x] Supergroup upgrade
|
||||||
|
* [x] Topics (spaces)
|
||||||
* Misc
|
* Misc
|
||||||
* [x] Automatic portal creation
|
* [x] Automatic portal creation
|
||||||
* [x] At startup
|
* [x] At startup
|
||||||
* [x] When receiving invite or message
|
* [x] When receiving invite or message
|
||||||
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
|
* [x] Private chat creation by inviting Matrix ghost of Telegram user to new room
|
||||||
* [x] Option to use bot to relay messages for unauthenticated Matrix users
|
* [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot)
|
||||||
* [ ] Option to use own Matrix account for messages sent from other Telegram clients
|
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
|
||||||
* [ ] ‡ Calls (hard, not yet supported by Telethon)
|
* [ ] ‡ Calls
|
||||||
|
* [ ] ‡ Secret chats (i.e. end-to-bridge encryption on Telegram)
|
||||||
|
|
||||||
† Information not automatically sent from source, i.e. implementation may not be possible
|
† Information not automatically sent from source, i.e. implementation may not be possible
|
||||||
‡ Maybe, i.e. this feature may or may not be implemented at some point
|
‡ Maybe, i.e. this feature may or may not be implemented at some point
|
||||||
|
|||||||
-71
@@ -1,71 +0,0 @@
|
|||||||
# A generic, single database configuration.
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
# path to migration scripts
|
|
||||||
script_location = alembic
|
|
||||||
|
|
||||||
# template used to generate migration files
|
|
||||||
# file_template = %%(rev)s_%%(slug)s
|
|
||||||
|
|
||||||
# timezone to use when rendering the date
|
|
||||||
# within the migration file as well as the filename.
|
|
||||||
# string value is passed to dateutil.tz.gettz()
|
|
||||||
# leave blank for localtime
|
|
||||||
# timezone =
|
|
||||||
|
|
||||||
# max length of characters to apply to the
|
|
||||||
# "slug" field
|
|
||||||
#truncate_slug_length = 40
|
|
||||||
|
|
||||||
# set to 'true' to run the environment during
|
|
||||||
# the 'revision' command, regardless of autogenerate
|
|
||||||
# revision_environment = false
|
|
||||||
|
|
||||||
# set to 'true' to allow .pyc and .pyo files without
|
|
||||||
# a source .py file to be detected as revisions in the
|
|
||||||
# versions/ directory
|
|
||||||
# sourceless = false
|
|
||||||
|
|
||||||
# version location specification; this defaults
|
|
||||||
# to alembic/versions. When using multiple version
|
|
||||||
# directories, initial revisions must be specified with --version-path
|
|
||||||
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
|
|
||||||
|
|
||||||
# the output encoding used when revision files
|
|
||||||
# are written from script.py.mako
|
|
||||||
# output_encoding = utf-8
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Generic single-database configuration.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
from alembic import context
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from os.path import abspath, dirname
|
|
||||||
|
|
||||||
sys.path.insert(0, dirname(dirname(abspath(__file__))))
|
|
||||||
|
|
||||||
from mautrix_telegram.base import Base
|
|
||||||
from mautrix_telegram.config import Config
|
|
||||||
from alchemysession import AlchemySessionContainer
|
|
||||||
import mautrix_telegram.db
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
|
|
||||||
mxtg_config = Config(mxtg_config_path, None, None)
|
|
||||||
mxtg_config.load()
|
|
||||||
config.set_main_option("sqlalchemy.url",
|
|
||||||
mxtg_config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDB:
|
|
||||||
@staticmethod
|
|
||||||
def query_property():
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base)
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
# from myapp import mymodel
|
|
||||||
# target_metadata = mymodel.Base.metadata
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
|
||||||
# can be acquired:
|
|
||||||
# my_important_option = config.get_main_option("my_important_option")
|
|
||||||
# ... etc.
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
|
||||||
"""Run migrations in 'offline' mode.
|
|
||||||
|
|
||||||
This configures the context with just a URL
|
|
||||||
and not an Engine, though an Engine is acceptable
|
|
||||||
here as well. By skipping the Engine creation
|
|
||||||
we don't even need a DBAPI to be available.
|
|
||||||
|
|
||||||
Calls to context.execute() here emit the given string to the
|
|
||||||
script output.
|
|
||||||
|
|
||||||
"""
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(
|
|
||||||
url=url, target_metadata=target_metadata, literal_binds=True)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online():
|
|
||||||
"""Run migrations in 'online' mode.
|
|
||||||
|
|
||||||
In this scenario we need to create an Engine
|
|
||||||
and associate a connection with the context.
|
|
||||||
|
|
||||||
"""
|
|
||||||
connectable = engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section),
|
|
||||||
prefix='sqlalchemy.',
|
|
||||||
poolclass=pool.NullPool)
|
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(
|
|
||||||
connection=connection,
|
|
||||||
target_metadata=target_metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = ${repr(up_revision)}
|
|
||||||
down_revision = ${repr(down_revision)}
|
|
||||||
branch_labels = ${repr(branch_labels)}
|
|
||||||
depends_on = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Add TelegramFile table
|
|
||||||
|
|
||||||
Revision ID: 1b241f7e8530
|
|
||||||
Revises: 97d2a942bcf8
|
|
||||||
Create Date: 2018-02-19 23:52:06.605741
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '1b241f7e8530'
|
|
||||||
down_revision = '97d2a942bcf8'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.create_table('telegram_file',
|
|
||||||
sa.Column('id', sa.String(), nullable=False),
|
|
||||||
sa.Column('mxc', sa.String(), nullable=True),
|
|
||||||
sa.Column('mime_type', sa.String(), nullable=True),
|
|
||||||
sa.Column('was_converted', sa.Boolean(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_table('telegram_file')
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""Add is_bot field to puppets
|
|
||||||
|
|
||||||
Revision ID: 1fa46383a9d3
|
|
||||||
Revises: 30eca60587f1
|
|
||||||
Create Date: 2018-04-29 23:44:40.102333
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '1fa46383a9d3'
|
|
||||||
down_revision = '30eca60587f1'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.add_column('puppet', sa.Column('is_bot', sa.Boolean(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_column('puppet', 'is_bot')
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""Add cascade rules to UserPortal
|
|
||||||
|
|
||||||
Revision ID: 2228d49c383f
|
|
||||||
Revises: bcfefa1f1299
|
|
||||||
Create Date: 2018-05-31 11:11:59.482112
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '2228d49c383f'
|
|
||||||
down_revision = 'bcfefa1f1299'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
try:
|
|
||||||
with op.batch_alter_table("user_portal") as batch_op:
|
|
||||||
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
|
|
||||||
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
|
|
||||||
batch_op.create_foreign_key("user_portal_user_fkey", "user", ["user"], ["tgid"],
|
|
||||||
onupdate="CASCADE", ondelete="CASCADE")
|
|
||||||
batch_op.create_foreign_key("user_portal_portal_fkey", "portal",
|
|
||||||
["portal", "portal_receiver"], ["tgid", "tg_receiver"],
|
|
||||||
onupdate="CASCADE", ondelete="CASCADE")
|
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
try:
|
|
||||||
with op.batch_alter_table("user_portal") as batch_op:
|
|
||||||
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
|
|
||||||
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
|
|
||||||
batch_op.create_foreign_key("user_portal_user_fkey", "portal",
|
|
||||||
["portal", "portal_receiver"], ["tgid", "tg_receiver"])
|
|
||||||
batch_op.create_foreign_key("user_portal_portal_fkey", "user", ["user"], ["tgid"])
|
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""Add megagroup field to portals
|
|
||||||
|
|
||||||
Revision ID: 30eca60587f1
|
|
||||||
Revises: cfc972368e50
|
|
||||||
Create Date: 2018-04-29 15:51:04.656605
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '30eca60587f1'
|
|
||||||
down_revision = 'cfc972368e50'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.add_column('portal', sa.Column('megagroup', sa.Boolean()))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_column('portal', 'megagroup')
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""Move sessions to main database
|
|
||||||
|
|
||||||
Revision ID: 501dad2868bc
|
|
||||||
Revises: 7d47d84380b6
|
|
||||||
Create Date: 2018-03-02 19:15:53.826985
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '501dad2868bc'
|
|
||||||
down_revision = '7d47d84380b6'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
Session = op.create_table('telethon_sessions',
|
|
||||||
sa.Column('session_id', sa.String, nullable=False),
|
|
||||||
sa.Column('dc_id', sa.Integer, nullable=False),
|
|
||||||
sa.Column('server_address', sa.String, nullable=True),
|
|
||||||
sa.Column('port', sa.Integer, nullable=True),
|
|
||||||
sa.Column('auth_key', sa.LargeBinary, nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('session_id', 'dc_id'))
|
|
||||||
SentFile = op.create_table('telethon_sent_files',
|
|
||||||
sa.Column('session_id', sa.String, nullable=False),
|
|
||||||
sa.Column('md5_digest', sa.LargeBinary, nullable=False),
|
|
||||||
sa.Column('file_size', sa.Integer, nullable=False),
|
|
||||||
sa.Column('type', sa.Integer, nullable=False),
|
|
||||||
sa.Column('id', sa.BigInteger, nullable=True),
|
|
||||||
sa.Column('hash', sa.BigInteger, nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('session_id', 'md5_digest', 'file_size',
|
|
||||||
'type'))
|
|
||||||
Entity = op.create_table('telethon_entities',
|
|
||||||
sa.Column('session_id', sa.String, nullable=False),
|
|
||||||
sa.Column('id', sa.Integer, nullable=False),
|
|
||||||
sa.Column('hash', sa.Integer, nullable=False),
|
|
||||||
sa.Column('username', sa.String, nullable=True),
|
|
||||||
sa.Column('phone', sa.Integer, nullable=True),
|
|
||||||
sa.Column('name', sa.String, nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('session_id', 'id'))
|
|
||||||
Version = op.create_table('telethon_version',
|
|
||||||
sa.Column('version', sa.Integer, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('version'))
|
|
||||||
conn = op.get_bind()
|
|
||||||
sessions = [os.path.basename(f) for f in os.listdir(".") if f.endswith(".session")]
|
|
||||||
for session in sessions:
|
|
||||||
session_to_sqlalchemy(conn, session, Session, SentFile, Entity)
|
|
||||||
|
|
||||||
|
|
||||||
def session_to_sqlalchemy(conn, path, Session, SentFile, Entity):
|
|
||||||
session_conn = sqlite3.connect(path)
|
|
||||||
session_id = os.path.splitext(path)[0]
|
|
||||||
c = session_conn.cursor()
|
|
||||||
|
|
||||||
auth_data_tuples = c.execute("SELECT * FROM sessions").fetchall()
|
|
||||||
auth_data_dicts = []
|
|
||||||
for row in auth_data_tuples:
|
|
||||||
dc_id, server_address, port, auth_key = row
|
|
||||||
auth_data_dicts.append({
|
|
||||||
"session_id": session_id,
|
|
||||||
"dc_id": dc_id,
|
|
||||||
"server_address": server_address,
|
|
||||||
"port": port,
|
|
||||||
"auth_key": auth_key,
|
|
||||||
})
|
|
||||||
if auth_data_dicts:
|
|
||||||
conn.execute(Session.insert().values(auth_data_dicts))
|
|
||||||
|
|
||||||
sent_file_tuples = c.execute("SELECT * FROM sent_files").fetchall()
|
|
||||||
sent_file_dicts = []
|
|
||||||
for row in sent_file_tuples:
|
|
||||||
md5_digest, file_size, type, id, hash = row
|
|
||||||
sent_file_dicts.append({
|
|
||||||
"session_id": session_id,
|
|
||||||
"md5_digest": md5_digest,
|
|
||||||
"file_size": file_size,
|
|
||||||
"type": type,
|
|
||||||
"id": id,
|
|
||||||
"hash": hash,
|
|
||||||
})
|
|
||||||
if sent_file_dicts:
|
|
||||||
conn.execute(SentFile.insert().values(sent_file_dicts))
|
|
||||||
|
|
||||||
entity_tuples = c.execute("SELECT * FROM entities").fetchall()
|
|
||||||
entity_dicts = []
|
|
||||||
for row in entity_tuples:
|
|
||||||
id, hash, username, phone, name = row
|
|
||||||
entity_dicts.append({
|
|
||||||
"session_id": session_id,
|
|
||||||
"id": id,
|
|
||||||
"hash": hash,
|
|
||||||
"username": username,
|
|
||||||
"phone": phone,
|
|
||||||
"name": name,
|
|
||||||
})
|
|
||||||
if entity_dicts:
|
|
||||||
conn.execute(Entity.insert().values(entity_dicts))
|
|
||||||
|
|
||||||
c.close()
|
|
||||||
session_conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_table('telethon_version')
|
|
||||||
op.drop_table('telethon_entities')
|
|
||||||
op.drop_table('telethon_sent_files')
|
|
||||||
op.drop_table('telethon_sessions')
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""Move state store to main database
|
|
||||||
|
|
||||||
Revision ID: 6ca3d74d51e4
|
|
||||||
Revises: 2228d49c383f
|
|
||||||
Create Date: 2018-06-26 21:31:26.911307
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import context, op
|
|
||||||
import sqlalchemy.orm as orm
|
|
||||||
import sqlalchemy as sa
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
from mautrix_telegram.config import Config
|
|
||||||
from mautrix_telegram.base import Base
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = "6ca3d74d51e4"
|
|
||||||
down_revision = "2228d49c383f"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
class RoomState(Base):
|
|
||||||
query = None
|
|
||||||
__tablename__ = "mx_room_state"
|
|
||||||
__table_args__ = {"extend_existing": True}
|
|
||||||
|
|
||||||
room_id = sa.Column(sa.String, primary_key=True)
|
|
||||||
power_levels = sa.Column("power_levels", sa.Text, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(Base):
|
|
||||||
query = None
|
|
||||||
__tablename__ = "mx_user_profile"
|
|
||||||
__table_args__ = {"extend_existing": True}
|
|
||||||
|
|
||||||
room_id = sa.Column(sa.String, primary_key=True)
|
|
||||||
user_id = sa.Column(sa.String, primary_key=True)
|
|
||||||
membership = sa.Column(sa.String, nullable=False, default="leave")
|
|
||||||
displayname = sa.Column(sa.String, nullable=True)
|
|
||||||
avatar_url = sa.Column(sa.String, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Puppet(Base):
|
|
||||||
query = None
|
|
||||||
__tablename__ = "puppet"
|
|
||||||
__table_args__ = {"extend_existing": True}
|
|
||||||
|
|
||||||
id = sa.Column(sa.Integer, primary_key=True)
|
|
||||||
displayname = sa.Column(sa.String, nullable=True)
|
|
||||||
displayname_source = sa.Column(sa.Integer, nullable=True)
|
|
||||||
username = sa.Column(sa.String, nullable=True)
|
|
||||||
photo_id = sa.Column(sa.String, nullable=True)
|
|
||||||
is_bot = sa.Column(sa.Boolean, nullable=True)
|
|
||||||
matrix_registered = sa.Column(sa.Boolean, nullable=False, default=False)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.add_column("puppet", sa.Column("matrix_registered", sa.Boolean(), nullable=False,
|
|
||||||
server_default=sa.sql.expression.false()))
|
|
||||||
op.create_table("mx_room_state",
|
|
||||||
sa.Column("room_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("power_levels", sa.Text(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint("room_id"))
|
|
||||||
op.create_table("mx_user_profile",
|
|
||||||
sa.Column("room_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("user_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("membership", sa.String(), nullable=False,
|
|
||||||
default="leave"),
|
|
||||||
sa.Column("displayname", sa.String(), nullable=True),
|
|
||||||
sa.Column("avatar_url", sa.String(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint("room_id", "user_id"))
|
|
||||||
|
|
||||||
conn = op.get_bind()
|
|
||||||
session = orm.sessionmaker(bind=conn)
|
|
||||||
session = orm.scoping.scoped_session(session)
|
|
||||||
Puppet.query = session.query_property()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open("mx-state.json") as file:
|
|
||||||
data = json.load(file)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return
|
|
||||||
if not data:
|
|
||||||
return
|
|
||||||
registrations = data.get("registrations", [])
|
|
||||||
|
|
||||||
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
|
|
||||||
mxtg_config = Config(mxtg_config_path, None, None)
|
|
||||||
mxtg_config.load()
|
|
||||||
|
|
||||||
username_template = mxtg_config.get("bridge.username_template", "telegram_{userid}")
|
|
||||||
hs_domain = mxtg_config["homeserver.domain"]
|
|
||||||
localpart = username_template.format(userid="(.+)")
|
|
||||||
mxid_regex = re.compile("@{}:{}".format(localpart, hs_domain))
|
|
||||||
for user in registrations:
|
|
||||||
match = mxid_regex.match(user)
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
|
|
||||||
puppet = Puppet.query.get(match.group(1))
|
|
||||||
if not puppet:
|
|
||||||
continue
|
|
||||||
|
|
||||||
puppet.matrix_registered = True
|
|
||||||
session.merge(puppet)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
user_profiles = [UserProfile(room_id=room, user_id=user,
|
|
||||||
membership=member.get("membership", "leave"),
|
|
||||||
displayname=member.get("displayname", None),
|
|
||||||
avatar_url=member.get("avatar_url", None))
|
|
||||||
for room, members in data.get("members", {}).items()
|
|
||||||
for user, member in members.items()]
|
|
||||||
session.add_all(user_profiles)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
room_state = [RoomState(room_id=room, power_levels=json.dumps(levels))
|
|
||||||
for room, levels in data.get("power_levels", {}).items()]
|
|
||||||
session.add_all(room_state)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_table("mx_user_profile")
|
|
||||||
op.drop_table("mx_room_state")
|
|
||||||
with op.batch_alter_table("puppet") as batch_op:
|
|
||||||
batch_op.drop_column("matrix_registered")
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Add timestamp to TelegramFile
|
|
||||||
|
|
||||||
Revision ID: 7d47d84380b6
|
|
||||||
Revises: 1b241f7e8530
|
|
||||||
Create Date: 2018-02-19 23:53:18.050871
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '7d47d84380b6'
|
|
||||||
down_revision = '1b241f7e8530'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.add_column('telegram_file',
|
|
||||||
sa.Column('timestamp', sa.BigInteger(), nullable=True, default=0,
|
|
||||||
server_default="0"))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_column('telegram_file', 'timestamp')
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""initial revision
|
|
||||||
|
|
||||||
Revision ID: 97d2a942bcf8
|
|
||||||
Revises:
|
|
||||||
Create Date: 2018-02-11 18:40:55.483842
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '97d2a942bcf8'
|
|
||||||
down_revision = None
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.create_table('portal',
|
|
||||||
sa.Column('tgid', sa.Integer),
|
|
||||||
sa.Column('tg_receiver', sa.Integer),
|
|
||||||
sa.Column('peer_type', sa.String, nullable=False, default=""),
|
|
||||||
sa.Column('mxid', sa.String, nullable=True),
|
|
||||||
sa.Column('username', sa.String, nullable=True),
|
|
||||||
sa.Column('title', sa.String, nullable=True),
|
|
||||||
sa.Column('about', sa.String, nullable=True),
|
|
||||||
sa.Column('photo_id', sa.String, nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('tgid', 'tg_receiver'),
|
|
||||||
sa.UniqueConstraint('mxid'))
|
|
||||||
op.create_table('user',
|
|
||||||
sa.Column('mxid', sa.String),
|
|
||||||
sa.Column('tgid', sa.Integer, nullable=True, unique=True),
|
|
||||||
sa.Column('tg_username', sa.String, nullable=True),
|
|
||||||
sa.Column('saved_contacts', sa.Integer, nullable=False, default=0),
|
|
||||||
sa.PrimaryKeyConstraint('mxid'))
|
|
||||||
op.create_table('puppet',
|
|
||||||
sa.Column('id', sa.Integer),
|
|
||||||
sa.Column('displayname', sa.String, nullable=True),
|
|
||||||
sa.Column('username', sa.String, nullable=True),
|
|
||||||
sa.Column('photo_id', sa.String, nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'))
|
|
||||||
op.create_table('contact',
|
|
||||||
sa.Column('user', sa.Integer),
|
|
||||||
sa.Column('contact', sa.Integer),
|
|
||||||
sa.ForeignKeyConstraint(("user",), ("user.tgid",)),
|
|
||||||
sa.ForeignKeyConstraint(("contact",), ("puppet.id",)),
|
|
||||||
sa.PrimaryKeyConstraint('user', 'contact'))
|
|
||||||
op.create_table('user_portal',
|
|
||||||
sa.Column('user', sa.Integer),
|
|
||||||
sa.Column('portal', sa.Integer),
|
|
||||||
sa.Column('portal_receiver', sa.Integer),
|
|
||||||
sa.PrimaryKeyConstraint('user', 'portal', 'portal_receiver'),
|
|
||||||
sa.ForeignKeyConstraint(("user",), ("user.tgid",),
|
|
||||||
name="user_portal_user_fkey",
|
|
||||||
onupdate="CASCADE", ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(("portal", "portal_receiver"),
|
|
||||||
("portal.tgid", "portal.tg_receiver"),
|
|
||||||
name="user_portal_portal_fkey",
|
|
||||||
onupdate="CASCADE", ondelete="CASCADE"))
|
|
||||||
op.create_table('message',
|
|
||||||
sa.Column('mxid', sa.String),
|
|
||||||
sa.Column('mx_room', sa.String),
|
|
||||||
sa.Column('tgid', sa.Integer),
|
|
||||||
sa.Column('tg_space', sa.Integer),
|
|
||||||
sa.PrimaryKeyConstraint('tgid', 'tg_space'),
|
|
||||||
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"))
|
|
||||||
op.create_table('bot_chat',
|
|
||||||
sa.Column('id', sa.Integer),
|
|
||||||
sa.Column('type', sa.String, nullable=False, default=""),
|
|
||||||
sa.PrimaryKeyConstraint('id'))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_table('bot_chat')
|
|
||||||
op.drop_table('message')
|
|
||||||
op.drop_table('user_portal')
|
|
||||||
op.drop_table('contact')
|
|
||||||
op.drop_table('puppet')
|
|
||||||
op.drop_table('user')
|
|
||||||
op.drop_table('portal')
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""Add displayname source fields for puppets
|
|
||||||
|
|
||||||
Revision ID: bcfefa1f1299
|
|
||||||
Revises: bdadd173ee02
|
|
||||||
Create Date: 2018-05-19 17:00:21.078098
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'bcfefa1f1299'
|
|
||||||
down_revision = 'bdadd173ee02'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.add_column('puppet', sa.Column('displayname_source', sa.Integer(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_column('puppet', 'displayname_source')
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Update telethon update state table
|
|
||||||
|
|
||||||
Revision ID: bdadd173ee02
|
|
||||||
Revises: eeaf0dae87ce
|
|
||||||
Create Date: 2018-05-13 10:42:59.395597
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'bdadd173ee02'
|
|
||||||
down_revision = 'eeaf0dae87ce'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
with op.batch_alter_table("telethon_entities") as batch_op:
|
|
||||||
batch_op.alter_column("id", existing_type=sa.Integer, type_=sa.BigInteger)
|
|
||||||
batch_op.alter_column("hash", existing_type=sa.Integer, type_=sa.BigInteger)
|
|
||||||
|
|
||||||
with op.batch_alter_table("telethon_update_state") as batch_op:
|
|
||||||
batch_op.alter_column("entity_id", existing_type=sa.Integer, type_=sa.BigInteger)
|
|
||||||
batch_op.alter_column("pts", existing_type=sa.Integer, type_=sa.BigInteger)
|
|
||||||
batch_op.alter_column("qts", existing_type=sa.Integer, type_=sa.BigInteger)
|
|
||||||
batch_op.alter_column("date", existing_type=sa.Integer, type_=sa.BigInteger)
|
|
||||||
batch_op.alter_column("seq", existing_type=sa.Integer, type_=sa.BigInteger)
|
|
||||||
batch_op.add_column(sa.Column("unread_count", sa.Integer))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
with op.batch_alter_table("telethon_entities") as batch_op:
|
|
||||||
batch_op.alter_column("id", existing_type=sa.BigInteger, type_=sa.Integer)
|
|
||||||
batch_op.alter_column("hash", existing_type=sa.BigInteger, type_=sa.Integer)
|
|
||||||
|
|
||||||
with op.batch_alter_table("telethon_update_state") as batch_op:
|
|
||||||
batch_op.alter_column("entity_id", existing_type=sa.BigInteger, type_=sa.Integer)
|
|
||||||
batch_op.alter_column("pts", existing_type=sa.BigInteger, type_=sa.Integer)
|
|
||||||
batch_op.alter_column("qts", existing_type=sa.BigInteger, type_=sa.Integer)
|
|
||||||
batch_op.alter_column("date", existing_type=sa.BigInteger, type_=sa.Integer)
|
|
||||||
batch_op.alter_column("seq", existing_type=sa.BigInteger, type_=sa.Integer)
|
|
||||||
batch_op.drop_column("unread_count")
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""Add metadata to TelegramFile
|
|
||||||
|
|
||||||
Revision ID: cfc972368e50
|
|
||||||
Revises: 501dad2868bc
|
|
||||||
Create Date: 2018-03-09 16:07:01.236712
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'cfc972368e50'
|
|
||||||
down_revision = '501dad2868bc'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
with op.batch_alter_table("telegram_file") as batch_op:
|
|
||||||
batch_op.add_column(sa.Column('size', sa.Integer(), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column('width', sa.Integer(), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column('height', sa.Integer(), nullable=True))
|
|
||||||
batch_op.add_column(sa.Column('thumbnail', sa.String(), nullable=True))
|
|
||||||
batch_op.create_foreign_key(constraint_name="fk_file_thumbnail",
|
|
||||||
referent_table="telegram_file",
|
|
||||||
local_cols=['thumbnail'],
|
|
||||||
remote_cols=['id'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
with op.batch_alter_table("telegram_file") as batch_op:
|
|
||||||
batch_op.drop_column('size')
|
|
||||||
batch_op.drop_column('width')
|
|
||||||
batch_op.drop_column('height')
|
|
||||||
batch_op.drop_column('thumbnail')
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""Add access_token and custom_mxid fields for puppets
|
|
||||||
|
|
||||||
Revision ID: d5f7b8b4b456
|
|
||||||
Revises: 6ca3d74d51e4
|
|
||||||
Create Date: 2018-07-20 12:09:30.277960
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = "d5f7b8b4b456"
|
|
||||||
down_revision = "6ca3d74d51e4"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.add_column("puppet", sa.Column("access_token", sa.String(), nullable=True))
|
|
||||||
op.add_column("puppet", sa.Column("custom_mxid", sa.String(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
with op.batch_alter_table("puppet") as batch_op:
|
|
||||||
batch_op.drop_column("custom_mxid")
|
|
||||||
batch_op.drop_column("access_token")
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""Add telethon update state table
|
|
||||||
|
|
||||||
Revision ID: eeaf0dae87ce
|
|
||||||
Revises: 1fa46383a9d3
|
|
||||||
Create Date: 2018-04-30 17:30:59.610885
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'eeaf0dae87ce'
|
|
||||||
down_revision = '1fa46383a9d3'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
with op.batch_alter_table("telethon_entities") as batch_op:
|
|
||||||
batch_op.alter_column('phone', existing_type=sa.Integer, type_=sa.BigInteger)
|
|
||||||
op.create_table('telethon_update_state',
|
|
||||||
sa.Column('session_id', sa.String, nullable=False),
|
|
||||||
sa.Column('entity_id', sa.Integer, nullable=False),
|
|
||||||
sa.Column('pts', sa.Integer, nullable=True),
|
|
||||||
sa.Column('qts', sa.Integer, nullable=True),
|
|
||||||
sa.Column('date', sa.Integer, nullable=True),
|
|
||||||
sa.Column('seq', sa.Integer, nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('session_id', 'entity_id'))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
with op.batch_alter_table("telethon_entities") as batch_op:
|
|
||||||
batch_op.alter_column('phone', existing_type=sa.BigInteger, type_=sa.Integer)
|
|
||||||
op.drop_table('telethon_update_state')
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BINARY_NAME=mautrix-telegram go tool maubuild "$@"
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
up "go.mau.fi/util/configupgrade"
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
const legacyMigrateRenameTablesQuery = `
|
||||||
|
ALTER TABLE backfill_queue RENAME TO backfill_queue_old;
|
||||||
|
ALTER TABLE bot_chat RENAME TO bot_chat_old;
|
||||||
|
ALTER TABLE contact RENAME TO contact_old;
|
||||||
|
ALTER TABLE disappearing_message RENAME TO disappearing_message_old;
|
||||||
|
ALTER TABLE message RENAME TO message_old;
|
||||||
|
ALTER TABLE portal RENAME TO portal_old;
|
||||||
|
ALTER TABLE puppet RENAME TO puppet_old;
|
||||||
|
ALTER TABLE reaction RENAME TO reaction_old;
|
||||||
|
ALTER TABLE telegram_file RENAME TO telegram_file_old;
|
||||||
|
ALTER TABLE telethon_entities RENAME TO telethon_entities_old;
|
||||||
|
ALTER TABLE telethon_sent_files RENAME TO telethon_sent_files_old;
|
||||||
|
ALTER TABLE telethon_sessions RENAME TO telethon_sessions_old;
|
||||||
|
ALTER TABLE telethon_update_state RENAME TO telethon_update_state_old;
|
||||||
|
ALTER TABLE "user" RENAME TO user_old;
|
||||||
|
ALTER TABLE user_portal RENAME TO user_portal_old;
|
||||||
|
DROP INDEX IF EXISTS telegram_file_mxc_idx;
|
||||||
|
`
|
||||||
|
|
||||||
|
func legacyMigrateRenameTables(ctx context.Context, db *dbutil.Database) error {
|
||||||
|
_, err := db.Exec(ctx, legacyMigrateRenameTablesQuery)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var mxVersion int
|
||||||
|
err = db.QueryRow(ctx, "SELECT version FROM mx_version").Scan(&mxVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get mx_version: %w", err)
|
||||||
|
} else if mxVersion == 3 {
|
||||||
|
zerolog.Ctx(ctx).Debug().Msg("mx_version is 3, adding create_event column before running actual migration")
|
||||||
|
_, err = db.Exec(ctx, `
|
||||||
|
ALTER TABLE mx_room_state ADD COLUMN create_event TEXT;
|
||||||
|
UPDATE mx_version SET version=4;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to add create_event column to mx_room_state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed legacymigrate.sql
|
||||||
|
var legacyMigrateCopyData string
|
||||||
|
|
||||||
|
func migrateLegacyConfig(helper up.Helper) {
|
||||||
|
helper.Set(up.Str, "mautrix.bridge.e2ee", "encryption", "pickle_key")
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"telegram", "api_id"}, []string{"network", "api_id"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "api_hash"}, []string{"network", "api_hash"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "device_model"}, []string{"network", "device_info", "device_model"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "system_version"}, []string{"network", "device_info", "system_version"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "app_version"}, []string{"network", "device_info", "app_version"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "lang_code"}, []string{"network", "device_info", "lang_code"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "system_lang_code"}, []string{"network", "device_info", "system_lang_code"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "animated_sticker", "target"}, []string{"network", "animated_sticker", "target"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "animated_sticker", "convert_from_webm"}, []string{"network", "animated_sticker", "convert_from_webm"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "width"}, []string{"network", "animated_sticker", "width"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "height"}, []string{"network", "animated_sticker", "height"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "fps"}, []string{"network", "animated_sticker", "fps"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "max_initial_member_sync"}, []string{"network", "member_list", "max_initial_sync"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_channel_members"}, []string{"network", "member_list", "sync_broadcast_channels"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "skip_deleted_members"}, []string{"network", "member_list", "skip_deleted"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "type"}, []string{"network", "proxy", "type"})
|
||||||
|
proxyAddress, _ := helper.Get(up.Str, "telegram", "proxy", "address")
|
||||||
|
proxyPort, _ := helper.Get(up.Int, "telegram", "proxy", "port")
|
||||||
|
helper.Set(up.Str, fmt.Sprintf("%s:%s", proxyAddress, proxyPort), "network", "proxy", "address")
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "username"}, []string{"network", "proxy", "username"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "password"}, []string{"network", "proxy", "password"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "max_member_count"}, []string{"network", "max_member_count"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "sync_update_limit"}, []string{"network", "sync", "update_limit"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "sync_create_limit"}, []string{"network", "sync", "create_limit"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_direct_chats"}, []string{"network", "sync", "direct_chats"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "always_custom_emoji_reaction"}, []string{"network", "always_custom_emoji_reaction"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
INSERT INTO "user" (bridge_id, mxid)
|
||||||
|
SELECT '', mxid FROM user_old;
|
||||||
|
|
||||||
|
DELETE FROM telethon_sessions_old WHERE auth_key IS NULL;
|
||||||
|
ALTER TABLE telethon_sessions_old ADD COLUMN json_data jsonb;
|
||||||
|
UPDATE telethon_sessions_old SET json_data=
|
||||||
|
-- only: postgres
|
||||||
|
jsonb_build_object
|
||||||
|
-- only: sqlite (line commented)
|
||||||
|
-- json_object
|
||||||
|
(
|
||||||
|
'auth_key', encode(auth_key, 'base64'),
|
||||||
|
'dc_id', dc_id,
|
||||||
|
'server_address', server_address,
|
||||||
|
'port', port
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata)
|
||||||
|
SELECT
|
||||||
|
'', -- bridge_id
|
||||||
|
mxid, -- user_mxid
|
||||||
|
CAST(tgid AS TEXT), -- id
|
||||||
|
COALESCE(tg_username, tg_phone, ''), -- remote_name
|
||||||
|
'{}', -- remote_profile
|
||||||
|
'', -- space_room
|
||||||
|
-- only: postgres
|
||||||
|
jsonb_build_object
|
||||||
|
-- only: sqlite (line commented)
|
||||||
|
-- json_object
|
||||||
|
(
|
||||||
|
'phone', COALESCE('+' || tg_phone, ''),
|
||||||
|
'session', json((SELECT json_data FROM telethon_sessions_old WHERE session_id=mxid))
|
||||||
|
) -- metadata
|
||||||
|
FROM user_old
|
||||||
|
WHERE tgid IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO ghost (
|
||||||
|
bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
|
||||||
|
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'', -- bridge_id
|
||||||
|
CAST(id AS TEXT), -- id
|
||||||
|
COALESCE(displayname, ''), -- name
|
||||||
|
COALESCE(photo_id, ''), -- avatar_id
|
||||||
|
'', -- avatar_hash
|
||||||
|
COALESCE(avatar_url, ''), -- avatar_mxc
|
||||||
|
name_set,
|
||||||
|
avatar_set,
|
||||||
|
contact_info_set,
|
||||||
|
COALESCE(is_bot, false),
|
||||||
|
'[]', -- identifiers
|
||||||
|
-- only: postgres
|
||||||
|
jsonb_build_object
|
||||||
|
-- only: sqlite (line commented)
|
||||||
|
-- json_object
|
||||||
|
(
|
||||||
|
'is_premium', CASE WHEN is_premium THEN json('true') ELSE json('false') END,
|
||||||
|
'is_channel', CASE WHEN is_channel THEN json('true') ELSE json('false') END,
|
||||||
|
'contact_source', displayname_source,
|
||||||
|
'source_is_contact', CASE WHEN displayname_contact THEN json('true') ELSE json('false') END
|
||||||
|
) -- metadata
|
||||||
|
FROM puppet_old;
|
||||||
|
|
||||||
|
DELETE FROM user_portal_old WHERE portal IN (SELECT tgid FROM portal_old WHERE peer_type<>'channel');
|
||||||
|
DELETE FROM backfill_queue_old WHERE portal_tgid IN (SELECT tgid FROM portal_old WHERE peer_type<>'channel');
|
||||||
|
|
||||||
|
UPDATE portal_old
|
||||||
|
SET tg_receiver=COALESCE((SELECT "user" FROM user_portal_old WHERE portal=portal_old.tgid LIMIT 1), tg_receiver)
|
||||||
|
WHERE peer_type='chat' AND tgid=tg_receiver;
|
||||||
|
|
||||||
|
UPDATE portal_old
|
||||||
|
SET tg_receiver=COALESCE((SELECT tgid FROM user_old WHERE tgid IS NOT NULL LIMIT 1), tg_receiver)
|
||||||
|
WHERE peer_type='chat' AND tgid=tg_receiver;
|
||||||
|
|
||||||
|
DELETE FROM portal_old WHERE peer_type='chat' AND tgid=tg_receiver;
|
||||||
|
|
||||||
|
INSERT INTO portal (
|
||||||
|
bridge_id, id, receiver, mxid, other_user_id, name, topic, avatar_id, avatar_hash, avatar_mxc,
|
||||||
|
name_set, avatar_set, topic_set, name_is_custom, in_space, room_type, metadata
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'', -- bridge_id
|
||||||
|
peer_type || ':' || CAST(tgid AS TEXT), -- id
|
||||||
|
CASE WHEN peer_type='channel' THEN '' ELSE CAST(tg_receiver AS TEXT) END, -- receiver
|
||||||
|
mxid, -- mxid
|
||||||
|
CASE WHEN peer_type='user' THEN CAST(tgid AS TEXT) END, -- other_user_id
|
||||||
|
COALESCE(title, ''), -- name
|
||||||
|
COALESCE(about, ''), -- topic
|
||||||
|
COALESCE(photo_id, ''), -- avatar_id
|
||||||
|
'', -- avatar_hash
|
||||||
|
COALESCE(avatar_url, ''), -- avatar_mxc
|
||||||
|
name_set, -- name_set
|
||||||
|
avatar_set, -- avatar_set
|
||||||
|
false, -- topic_set
|
||||||
|
peer_type<>'user', -- name_is_custom
|
||||||
|
false, -- in_space
|
||||||
|
CASE WHEN peer_type='user' THEN 'dm' ELSE '' END, -- room_type
|
||||||
|
-- only: postgres
|
||||||
|
jsonb_build_object
|
||||||
|
-- only: sqlite (line commented)
|
||||||
|
-- json_object
|
||||||
|
(
|
||||||
|
'is_supergroup', CASE WHEN megagroup THEN json('true') ELSE json('false') END
|
||||||
|
) -- metadata
|
||||||
|
FROM portal_old;
|
||||||
|
|
||||||
|
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred)
|
||||||
|
SELECT
|
||||||
|
'', -- bridge_id
|
||||||
|
user_old.mxid, -- user_mxid
|
||||||
|
CAST(user_portal_old.user AS TEXT), -- login_id
|
||||||
|
portal_old.peer_type || ':' || CAST(user_portal_old.portal AS TEXT), -- portal_id
|
||||||
|
CASE WHEN peer_type='channel' THEN '' ELSE CAST(user_portal_old.portal_receiver AS TEXT) END, -- portal_receiver
|
||||||
|
false, -- in_space
|
||||||
|
false -- preferred
|
||||||
|
FROM user_portal_old
|
||||||
|
INNER JOIN user_old ON user_portal_old."user" = user_old.tgid
|
||||||
|
INNER JOIN portal_old ON user_portal_old.portal = portal_old.tgid and user_portal_old.portal_receiver = portal_old.tg_receiver;
|
||||||
|
|
||||||
|
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred)
|
||||||
|
SELECT
|
||||||
|
'', -- bridge_id
|
||||||
|
user_old.mxid, -- user_mxid
|
||||||
|
CAST(portal_old.tg_receiver AS TEXT), -- login_id
|
||||||
|
portal_old.peer_type || ':' || CAST(portal_old.tgid AS TEXT), -- portal_id
|
||||||
|
CAST(portal_old.tg_receiver AS TEXT), -- portal_receiver
|
||||||
|
false, -- in_space
|
||||||
|
false -- preferred
|
||||||
|
FROM portal_old
|
||||||
|
INNER JOIN user_old ON portal_old.tg_receiver = user_old.tgid
|
||||||
|
WHERE portal_old.tg_receiver<>portal_old.tgid
|
||||||
|
ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO ghost (bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata)
|
||||||
|
VALUES ('', '', '', '', '', '', false, false, false, false, '[]', '{}');
|
||||||
|
|
||||||
|
UPDATE message_old SET sender=NULL WHERE sender IS NOT NULL AND NOT EXISTS(SELECT 1 FROM puppet_old WHERE id=sender);
|
||||||
|
|
||||||
|
INSERT INTO message (
|
||||||
|
bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, timestamp, edit_count, metadata
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'', -- bridge_id
|
||||||
|
CASE WHEN tg_space=portal_old.tgid THEN (CAST(tg_space AS TEXT) || '.') ELSE '' END || CAST(message_old.tgid AS TEXT), -- id
|
||||||
|
'', -- part_id
|
||||||
|
message_old.mxid, -- mxid
|
||||||
|
portal_old.peer_type || ':' || CAST(portal_old.tgid AS TEXT), -- room_id
|
||||||
|
CASE WHEN portal_old.peer_type='channel' THEN '' ELSE CAST(portal_old.tg_receiver AS TEXT) END, -- room_receiver
|
||||||
|
COALESCE(CAST(sender AS TEXT), ''), -- sender_id
|
||||||
|
COALESCE(sender_mxid, ''),
|
||||||
|
0, -- timestamp
|
||||||
|
0, -- edit_count
|
||||||
|
-- only: postgres
|
||||||
|
jsonb_build_object
|
||||||
|
-- only: sqlite (line commented)
|
||||||
|
-- json_object
|
||||||
|
(
|
||||||
|
'content_hash', CASE WHEN content_hash IS NULL THEN '' ELSE encode(content_hash, 'base64') END
|
||||||
|
) -- metadata
|
||||||
|
FROM message_old
|
||||||
|
INNER JOIN portal_old ON mx_room=portal_old.mxid
|
||||||
|
WHERE (tg_space=portal_old.tgid OR tg_space=portal_old.tg_receiver) AND edit_index=0;
|
||||||
|
-- TODO migrate edit_index?
|
||||||
|
|
||||||
|
INSERT INTO reaction (
|
||||||
|
bridge_id, message_id, message_part_id, sender_id, emoji_id, room_id, room_receiver, mxid, timestamp, emoji, metadata
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'', -- bridge_id
|
||||||
|
message.id, -- message_id
|
||||||
|
message.part_id, -- message_part_id
|
||||||
|
CAST(tg_sender AS TEXT), -- sender_id
|
||||||
|
reaction, -- emoji_id
|
||||||
|
message.room_id, -- room_id
|
||||||
|
message.room_receiver, -- room_receiver
|
||||||
|
reaction_old.mxid, -- mxid
|
||||||
|
0, -- timestamp
|
||||||
|
reaction, -- emoji
|
||||||
|
'{}' -- metadata
|
||||||
|
FROM reaction_old
|
||||||
|
INNER JOIN message ON reaction_old.msg_mxid=message.mxid;
|
||||||
|
|
||||||
|
INSERT INTO telegram_access_hash (user_id, entity_type, entity_id, access_hash)
|
||||||
|
SELECT
|
||||||
|
user_old.tgid,
|
||||||
|
CASE WHEN id < 0 THEN 'channel' ELSE 'user' END,
|
||||||
|
CASE WHEN id < 0 THEN -id - 1000000000000 ELSE id END,
|
||||||
|
hash
|
||||||
|
FROM telethon_entities_old
|
||||||
|
LEFT JOIN user_old ON user_old.mxid=session_id
|
||||||
|
WHERE user_old.tgid IS NOT NULL AND hash<>0;
|
||||||
|
|
||||||
|
INSERT INTO telegram_user_state (user_id, pts, qts, date, seq)
|
||||||
|
SELECT user_old.tgid, pts, qts, date, seq
|
||||||
|
FROM telethon_update_state_old
|
||||||
|
LEFT JOIN user_old ON user_old.mxid=session_id
|
||||||
|
WHERE entity_id=0 AND user_old.tgid IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO telegram_channel_state (user_id, channel_id, pts)
|
||||||
|
SELECT user_old.tgid, entity_id, pts
|
||||||
|
FROM telethon_update_state_old
|
||||||
|
LEFT JOIN user_old ON user_old.mxid=session_id
|
||||||
|
WHERE entity_id<>0 AND user_old.tgid IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO telegram_username (username, entity_type, entity_id)
|
||||||
|
SELECT
|
||||||
|
username,
|
||||||
|
CASE WHEN id < 0 THEN 'channel' ELSE 'user' END,
|
||||||
|
CASE WHEN id < 0 THEN -id - 1000000000000 ELSE id END
|
||||||
|
FROM telethon_entities_old
|
||||||
|
WHERE username<>''
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO telegram_phone_number (phone_number, entity_id)
|
||||||
|
SELECT phone, id
|
||||||
|
FROM telethon_entities_old
|
||||||
|
WHERE phone<>''
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO telegram_file (id, mxc, mime_type, size, width, height, timestamp)
|
||||||
|
SELECT id, mxc, mime_type, size, width, height, timestamp
|
||||||
|
FROM telegram_file_old;
|
||||||
|
|
||||||
|
INSERT INTO disappearing_message (bridge_id, mx_room, mxid, type, timer, disappear_at)
|
||||||
|
SELECT
|
||||||
|
'', -- bridge_id
|
||||||
|
room_id,
|
||||||
|
event_id,
|
||||||
|
'after_send',
|
||||||
|
expiration_seconds * 1000000000,
|
||||||
|
expiration_ts * 1000000
|
||||||
|
FROM disappearing_message_old
|
||||||
|
WHERE expiration_ts<9999999999999 AND expiration_seconds<999999
|
||||||
|
AND room_id IN (SELECT mxid FROM portal WHERE mxid IS NOT NULL);
|
||||||
|
|
||||||
|
-- TODO do something with the bot_chat table?
|
||||||
|
|
||||||
|
-- Python -> Go mx_ table migration
|
||||||
|
-- only: postgres until "end only"
|
||||||
|
ALTER TABLE mx_room_state DROP COLUMN is_encrypted;
|
||||||
|
ALTER TABLE mx_room_state RENAME COLUMN has_full_member_list TO members_fetched;
|
||||||
|
UPDATE mx_room_state SET members_fetched=false WHERE members_fetched IS NULL;
|
||||||
|
ALTER TABLE mx_room_state ADD COLUMN join_rules jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE mx_room_state ALTER COLUMN power_levels TYPE jsonb USING power_levels::jsonb;
|
||||||
|
ALTER TABLE mx_room_state ALTER COLUMN encryption TYPE jsonb USING encryption::jsonb;
|
||||||
|
ALTER TABLE mx_room_state ALTER COLUMN create_event TYPE jsonb USING create_event::jsonb;
|
||||||
|
ALTER TABLE mx_room_state ALTER COLUMN members_fetched SET DEFAULT false;
|
||||||
|
ALTER TABLE mx_room_state ALTER COLUMN members_fetched SET NOT NULL;
|
||||||
|
-- end only postgres
|
||||||
|
-- only: sqlite until "end only"
|
||||||
|
CREATE TABLE new_mx_room_state (
|
||||||
|
room_id TEXT PRIMARY KEY,
|
||||||
|
power_levels jsonb,
|
||||||
|
encryption jsonb,
|
||||||
|
create_event jsonb,
|
||||||
|
join_rules jsonb,
|
||||||
|
members_fetched BOOLEAN NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO new_mx_room_state (room_id, encryption, power_levels, create_event, members_fetched)
|
||||||
|
SELECT room_id, encryption, power_levels, create_event, COALESCE(has_full_member_list, false)
|
||||||
|
FROM mx_room_state;
|
||||||
|
|
||||||
|
DROP TABLE mx_room_state;
|
||||||
|
ALTER TABLE new_mx_room_state RENAME TO mx_room_state;
|
||||||
|
-- end only sqlite
|
||||||
|
|
||||||
|
ALTER TABLE mx_user_profile ADD COLUMN name_skeleton bytea;
|
||||||
|
CREATE INDEX mx_user_profile_membership_idx ON mx_user_profile (room_id, membership);
|
||||||
|
CREATE INDEX mx_user_profile_name_skeleton_idx ON mx_user_profile (room_id, name_skeleton);
|
||||||
|
|
||||||
|
UPDATE mx_user_profile SET displayname='' WHERE displayname IS NULL;
|
||||||
|
UPDATE mx_user_profile SET avatar_url='' WHERE avatar_url IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE mx_registrations (
|
||||||
|
user_id TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE mx_version SET version=10;
|
||||||
|
DELETE FROM mx_user_profile WHERE room_id='' OR user_id='';
|
||||||
|
DELETE FROM mx_room_state WHERE room_id='';
|
||||||
|
|
||||||
|
DROP TABLE user_portal_old;
|
||||||
|
DROP TABLE backfill_queue_old;
|
||||||
|
DROP TABLE bot_chat_old;
|
||||||
|
DROP TABLE contact_old;
|
||||||
|
DROP TABLE disappearing_message_old;
|
||||||
|
DROP TABLE message_old;
|
||||||
|
DROP TABLE reaction_old;
|
||||||
|
DROP TABLE portal_old;
|
||||||
|
DROP TABLE puppet_old;
|
||||||
|
DROP TABLE user_old;
|
||||||
|
-- only: postgres (this is deleted separately for sqlite)
|
||||||
|
DROP TABLE telegram_file_old;
|
||||||
|
DROP TABLE telethon_entities_old;
|
||||||
|
DROP TABLE telethon_sent_files_old;
|
||||||
|
DROP TABLE telethon_sessions_old;
|
||||||
|
DROP TABLE telethon_update_state_old;
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2024 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"go.mau.fi/util/exhttp"
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/status"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
Username id.UserID `json:"username,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrCode string `json:"errcode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r response) WithState(state string) response {
|
||||||
|
r.State = state
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r response) WithMessage(message string) response {
|
||||||
|
r.Message = message
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r response) WithError(errCode, error string) response {
|
||||||
|
r.ErrCode = errCode
|
||||||
|
r.Error = error
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyLogin struct {
|
||||||
|
Process bridgev2.LoginProcess
|
||||||
|
NextStep *bridgev2.LoginStep
|
||||||
|
}
|
||||||
|
|
||||||
|
var inflightLegacyLoginsLock sync.RWMutex
|
||||||
|
var inflightLegacyLogins = map[id.UserID]*legacyLogin{}
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
Subprotocols: []string{"net.maunium.telegram.login"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyProvLoginQR(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := zerolog.Ctx(r.Context()).With().Str("prov_method", "qr_login").Logger()
|
||||||
|
ctx := log.WithContext(r.Context())
|
||||||
|
|
||||||
|
user := m.Matrix.Provisioning.GetUser(r)
|
||||||
|
resp := response{Username: user.MXID}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var loginProcess bridgev2.LoginProcess
|
||||||
|
var nextStep *bridgev2.LoginStep
|
||||||
|
if loginProcess, err = c.CreateLogin(ctx, user, connector.LoginFlowIDQR); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("create_login_failed", fmt.Sprintf("Failed to create a QR login process: %s", err.Error())))
|
||||||
|
} else if nextStep, err = loginProcess.Start(ctx); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("start_login_failed", fmt.Sprintf("Failed to start login process: %s", err.Error())))
|
||||||
|
} else if nextStep.StepID != connector.LoginStepIDShowQR {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected first step %s", nextStep.StepID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to upgrade connection to websocket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err := ws.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Err(err).Msg("Error closing websocket")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Read everything so SetCloseHandler() works
|
||||||
|
for {
|
||||||
|
_, _, err = ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
ws.SetCloseHandler(func(code int, text string) error {
|
||||||
|
log.Debug().Int("close_code", code).Msg("Login websocket closed, cancelling login")
|
||||||
|
cancel()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
switch nextStep.StepID {
|
||||||
|
case connector.LoginStepIDShowQR:
|
||||||
|
ws.WriteJSON(map[string]any{"code": nextStep.DisplayAndWaitParams.Data})
|
||||||
|
nextStep, err = loginProcess.(bridgev2.LoginProcessDisplayAndWait).Wait(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ws.WriteJSON(map[string]any{
|
||||||
|
"success": false,
|
||||||
|
"error": "qr_login_failed",
|
||||||
|
"message": fmt.Sprintf("Failed to login using QR code: %s", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case connector.LoginStepIDComplete:
|
||||||
|
ws.WriteJSON(map[string]any{"success": true})
|
||||||
|
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
|
||||||
|
return
|
||||||
|
case connector.LoginStepIDPassword:
|
||||||
|
inflightLegacyLoginsLock.Lock()
|
||||||
|
inflightLegacyLogins[user.MXID] = &legacyLogin{Process: loginProcess, NextStep: nextStep}
|
||||||
|
inflightLegacyLoginsLock.Unlock()
|
||||||
|
ws.WriteJSON(map[string]any{"success": false, "error": "password-needed"})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
ws.WriteJSON(map[string]any{
|
||||||
|
"success": false,
|
||||||
|
"error": "unexpected_step",
|
||||||
|
"message": fmt.Sprintf("Unexpected step in QR code login process %s", nextStep.StepID),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyProvLoginRequestCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "request_code").Logger()
|
||||||
|
ctx := log.WithContext(r.Context())
|
||||||
|
|
||||||
|
user := m.Matrix.Provisioning.GetUser(r)
|
||||||
|
resp := response{Username: user.MXID, State: "request"}
|
||||||
|
|
||||||
|
legacyProvRequestCodeReq := map[string]string{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&legacyProvRequestCodeReq); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
|
||||||
|
} else if phone, ok := legacyProvRequestCodeReq["phone"]; !ok || phone == "" {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("phone_missing", "Phone number missing"))
|
||||||
|
} else if loginProcess, err := c.CreateLogin(ctx, user, connector.LoginFlowIDPhone); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("create_login_failed", fmt.Sprintf("Failed to create a phone number login process: %s", err.Error())))
|
||||||
|
} else if firstStep, err := loginProcess.Start(ctx); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("start_login_failed", fmt.Sprintf("Failed to start login process: %s", err.Error())))
|
||||||
|
} else if firstStep.StepID != connector.LoginStepIDPhoneNumber {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected first step %s", firstStep.StepID)))
|
||||||
|
} else if nextStep, err := loginProcess.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDPhoneNumber: phone}); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_code_failed", fmt.Sprintf("Failed to request code: %s", err.Error())))
|
||||||
|
} else if nextStep.StepID != connector.LoginStepIDCode {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
|
||||||
|
} else {
|
||||||
|
inflightLegacyLoginsLock.Lock()
|
||||||
|
inflightLegacyLogins[user.MXID] = &legacyLogin{Process: loginProcess, NextStep: nextStep}
|
||||||
|
inflightLegacyLoginsLock.Unlock()
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusOK, resp.
|
||||||
|
WithState("code").
|
||||||
|
WithMessage("Code requested successfully. Check your SMS or Telegram app and enter the code below."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyProvLoginSendCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "send_code").Logger()
|
||||||
|
ctx := log.WithContext(r.Context())
|
||||||
|
|
||||||
|
user := m.Matrix.Provisioning.GetUser(r)
|
||||||
|
resp := response{Username: user.MXID, State: "code"}
|
||||||
|
|
||||||
|
legacyProvSendCodeReq := map[string]string{}
|
||||||
|
if inflightLogin, ok := inflightLegacyLogins[user.MXID]; !ok {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("no_login", "No login process in progress"))
|
||||||
|
} else if inflightLogin.NextStep.StepID != connector.LoginStepIDCode {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", inflightLogin.NextStep.StepID)))
|
||||||
|
} else if err := json.NewDecoder(r.Body).Decode(&legacyProvSendCodeReq); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
|
||||||
|
} else if code, ok := legacyProvSendCodeReq["code"]; !ok || code == "" {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("phone_code_missing", "You must provide the code from your phone."))
|
||||||
|
} else if nextStep, err := inflightLogin.Process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDCode: code}); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("send_code_failed", fmt.Sprintf("Failed to send code: %s", err.Error())))
|
||||||
|
} else if nextStep.StepID == connector.LoginStepIDPassword {
|
||||||
|
inflightLegacyLoginsLock.Lock()
|
||||||
|
defer inflightLegacyLoginsLock.Unlock()
|
||||||
|
inflightLegacyLogins[user.MXID].NextStep = nextStep
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusAccepted, resp.
|
||||||
|
WithState("password").
|
||||||
|
WithMessage("Code accepted, but you have 2-factor authentication enabled. Please enter your password."),
|
||||||
|
)
|
||||||
|
return // Don't delete the inflight login yet, we need to submit the password.
|
||||||
|
} else if nextStep.StepID == connector.LoginStepIDComplete {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithState("logged-in"))
|
||||||
|
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
|
||||||
|
} else {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got here, then there was an error, or the login is complete.
|
||||||
|
// Delete the in-flight login.
|
||||||
|
inflightLegacyLoginsLock.Lock()
|
||||||
|
delete(inflightLegacyLogins, user.MXID)
|
||||||
|
inflightLegacyLoginsLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyProvLoginSendPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "send_password").Logger()
|
||||||
|
ctx := log.WithContext(r.Context())
|
||||||
|
|
||||||
|
user := m.Matrix.Provisioning.GetUser(r)
|
||||||
|
resp := response{Username: user.MXID, State: "password"}
|
||||||
|
|
||||||
|
legacyProvSendPasswordReq := map[string]string{}
|
||||||
|
if inflightLogin, ok := inflightLegacyLogins[user.MXID]; !ok {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("no_login", "No login process in progress"))
|
||||||
|
} else if inflightLogin.NextStep.StepID != connector.LoginStepIDPassword {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", inflightLogin.NextStep.StepID)))
|
||||||
|
} else if err := json.NewDecoder(r.Body).Decode(&legacyProvSendPasswordReq); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
|
||||||
|
} else if password, ok := legacyProvSendPasswordReq["password"]; !ok || password == "" {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("password_missing", "You must provide your password."))
|
||||||
|
} else if nextStep, err := inflightLogin.Process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDPassword: password}); err != nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("send_password_failed", fmt.Sprintf("Failed to send password: %s", err.Error())))
|
||||||
|
} else if nextStep.StepID == connector.LoginStepIDComplete {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithState("logged-in").WithMessage(nextStep.Instructions))
|
||||||
|
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
|
||||||
|
} else {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got here, then there was an error, or the login is complete.
|
||||||
|
// Delete the in-flight login.
|
||||||
|
inflightLegacyLoginsLock.Lock()
|
||||||
|
delete(inflightLegacyLogins, user.MXID)
|
||||||
|
inflightLegacyLoginsLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyProvLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := m.Matrix.Provisioning.GetUser(r)
|
||||||
|
resp := response{Username: user.MXID}
|
||||||
|
logins := user.GetUserLogins()
|
||||||
|
if len(logins) == 0 {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithError("not logged in", "You're not logged in"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, login := range logins {
|
||||||
|
login.Client.(*connector.TelegramClient).LogoutRemote(r.Context())
|
||||||
|
}
|
||||||
|
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLoginComplete(ctx context.Context, user *bridgev2.User, newLogin *bridgev2.UserLogin) {
|
||||||
|
allLogins := user.GetUserLogins()
|
||||||
|
for _, login := range allLogins {
|
||||||
|
if login.ID != newLogin.ID {
|
||||||
|
login.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut, Reason: "LOGIN_OVERRIDDEN"}, bridgev2.DeleteOpts{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyProvContacts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := zerolog.Ctx(r.Context()).With().
|
||||||
|
Str("prov_method", "contacts").
|
||||||
|
Logger()
|
||||||
|
ctx := log.WithContext(r.Context())
|
||||||
|
|
||||||
|
var resp response
|
||||||
|
login := m.Matrix.Provisioning.GetLoginForRequest(w, r)
|
||||||
|
if login == nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusNotFound, resp.WithError(mautrix.MNotFound.ErrCode, "No login found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api := login.Client.(bridgev2.ContactListingNetworkAPI)
|
||||||
|
contacts, err := api.GetContactList(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to get contacts")
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to get contacts: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contactsMap := map[int64]*legacyContactInfo{}
|
||||||
|
for _, contact := range contacts {
|
||||||
|
peerType, id, err := ids.ParseUserID(contact.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to parse user id")
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to parse user id: %v", err)))
|
||||||
|
return
|
||||||
|
} else if peerType != ids.PeerTypeUser {
|
||||||
|
log.Err(err).Msg("Unexpected peer type")
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Unexpected peer type: %s", peerType)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if contact.UserInfo != nil {
|
||||||
|
contact.Ghost.UpdateInfo(ctx, contact.UserInfo)
|
||||||
|
}
|
||||||
|
contactsMap[id] = legacyContactInfoFromGhost(contact.Ghost)
|
||||||
|
}
|
||||||
|
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusOK, contactsMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
|
||||||
|
legacyResolveIdentifierOrStartChat(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyProvPM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
legacyResolveIdentifierOrStartChat(w, r, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyResolveIdentifierResponse struct {
|
||||||
|
RoomID id.RoomID `json:"room_id,omitempty"`
|
||||||
|
JustCreated bool `json:"just_created,omitempty"`
|
||||||
|
ID int `json:"id,omitempty"`
|
||||||
|
ContactInfo *legacyContactInfo `json:"contact_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyContactInfo struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
IsBot bool `json:"is_bot,omitempty"`
|
||||||
|
AvatarURL id.ContentURIString `json:"avatar_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyContactInfoFromGhost(ghost *bridgev2.Ghost) *legacyContactInfo {
|
||||||
|
var username, phone string
|
||||||
|
for _, id := range ghost.Identifiers {
|
||||||
|
if strings.HasPrefix(id, "telegram:") {
|
||||||
|
username = strings.TrimPrefix(id, "telegram:")
|
||||||
|
} else if strings.HasPrefix(id, "tel:") {
|
||||||
|
phone = strings.TrimPrefix(id, "tel:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &legacyContactInfo{
|
||||||
|
Name: ghost.Name,
|
||||||
|
Username: username,
|
||||||
|
Phone: phone,
|
||||||
|
IsBot: ghost.IsBot,
|
||||||
|
AvatarURL: ghost.AvatarMXC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, create bool) {
|
||||||
|
log := zerolog.Ctx(r.Context()).With().
|
||||||
|
Str("prov_method", "resolve_identifier").
|
||||||
|
Bool("create", create).
|
||||||
|
Logger()
|
||||||
|
ctx := log.WithContext(r.Context())
|
||||||
|
|
||||||
|
var resp response
|
||||||
|
login := m.Matrix.Provisioning.GetLoginForRequest(w, r)
|
||||||
|
if login == nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusNotFound, resp.WithError(mautrix.MNotFound.ErrCode, "No login found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api := login.Client.(bridgev2.IdentifierResolvingNetworkAPI)
|
||||||
|
identResp, err := api.ResolveIdentifier(ctx, r.PathValue("identifier"), create)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to resolve identifier")
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
|
||||||
|
resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to resolve identifier: %v", err)))
|
||||||
|
return
|
||||||
|
} else if identResp == nil {
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusNotFound,
|
||||||
|
resp.WithError(mautrix.MNotFound.ErrCode, "User not found on Telegram"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := http.StatusOK
|
||||||
|
var apiResp legacyResolveIdentifierResponse
|
||||||
|
if identResp.Ghost != nil {
|
||||||
|
if identResp.UserInfo != nil {
|
||||||
|
identResp.Ghost.UpdateInfo(ctx, identResp.UserInfo)
|
||||||
|
}
|
||||||
|
apiResp.ContactInfo = legacyContactInfoFromGhost(identResp.Ghost)
|
||||||
|
}
|
||||||
|
if identResp.Chat != nil {
|
||||||
|
if identResp.Chat.Portal == nil {
|
||||||
|
identResp.Chat.Portal, err = m.Bridge.GetPortalByKey(ctx, identResp.Chat.PortalKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to get portal")
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
|
||||||
|
resp.WithError("M_UNKNOWN", "Failed to get portal"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if create && identResp.Chat.Portal.MXID == "" {
|
||||||
|
apiResp.JustCreated = true
|
||||||
|
status = http.StatusCreated
|
||||||
|
err = identResp.Chat.Portal.CreateMatrixRoom(ctx, login, identResp.Chat.PortalInfo)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to create portal room")
|
||||||
|
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
|
||||||
|
resp.WithError("M_UNKNOWN", "Failed to create portal room"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiResp.RoomID = identResp.Chat.Portal.MXID
|
||||||
|
}
|
||||||
|
exhttp.WriteJSONResponse(w, status, &apiResp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2024 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil/litestream"
|
||||||
|
"go.mau.fi/util/exerrors"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/store/upgrades"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Information to find out exactly which commit the bridge was built from.
|
||||||
|
// These are filled at build time with the -X linker flag.
|
||||||
|
var (
|
||||||
|
Tag = "unknown"
|
||||||
|
Commit = "unknown"
|
||||||
|
BuildTime = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
var c = &connector.TelegramConnector{}
|
||||||
|
var m = mxmain.BridgeMain{
|
||||||
|
Name: "mautrix-telegram",
|
||||||
|
URL: "https://github.com/mautrix/telegram",
|
||||||
|
Description: "A Matrix-Telegram puppeting bridge.",
|
||||||
|
Version: "26.04",
|
||||||
|
SemCalVer: true,
|
||||||
|
|
||||||
|
Connector: c,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
litestream.Functions["encode"] = func(data []byte, encoding string) string {
|
||||||
|
if encoding == "base64" {
|
||||||
|
return base64.StdEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
|
panic(fmt.Errorf("unknown encoding %q", encoding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
bridgeconfig.HackyMigrateLegacyNetworkConfig = migrateLegacyConfig
|
||||||
|
versionWithoutCommit := m.Version
|
||||||
|
m.PostStart = func() {
|
||||||
|
if m.Matrix.Provisioning != nil {
|
||||||
|
m.Matrix.Provisioning.GetAuthFromRequest = func(r *http.Request) string {
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/login/qr") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
|
||||||
|
for _, part := range authParts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if strings.HasPrefix(part, "net.maunium.telegram.auth-") {
|
||||||
|
return strings.TrimPrefix(part, "net.maunium.telegram.auth-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m.Matrix.Provisioning.GetUserIDFromRequest = func(r *http.Request) id.UserID {
|
||||||
|
return id.UserID(r.PathValue("userID"))
|
||||||
|
}
|
||||||
|
m.Matrix.Provisioning.Router.HandleFunc("/v1/user/{userID}/login/qr", legacyProvLoginQR)
|
||||||
|
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/request_code", legacyProvLoginRequestCode)
|
||||||
|
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/send_code", legacyProvLoginSendCode)
|
||||||
|
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/send_password", legacyProvLoginSendPassword)
|
||||||
|
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/logout", legacyProvLogout)
|
||||||
|
m.Matrix.Provisioning.Router.HandleFunc("GET /v1/user/{userID}/contacts", legacyProvContacts)
|
||||||
|
m.Matrix.Provisioning.Router.HandleFunc("/v1/user/{userID}/resolve_identifier/{identifier}", legacyProvResolveIdentifier)
|
||||||
|
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/pm/{identifier}", legacyProvPM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.PostInit = func() {
|
||||||
|
if c.Config.DeviceInfo.AppVersion == "auto" {
|
||||||
|
c.Config.DeviceInfo.AppVersion = versionWithoutCommit
|
||||||
|
}
|
||||||
|
if c.Config.DeviceInfo.SystemVersion == "auto" {
|
||||||
|
c.Config.DeviceInfo.SystemVersion = ""
|
||||||
|
}
|
||||||
|
if c.Config.DeviceInfo.DeviceModel == "auto" || c.Config.DeviceInfo.DeviceModel == "" {
|
||||||
|
c.Config.DeviceInfo.DeviceModel = "mautrix-telegram"
|
||||||
|
}
|
||||||
|
m.CheckLegacyDB(
|
||||||
|
18,
|
||||||
|
"v0.14.0",
|
||||||
|
"v26.04",
|
||||||
|
m.LegacyMigrateWithAnotherUpgrader(
|
||||||
|
legacyMigrateRenameTables, legacyMigrateCopyData, 27,
|
||||||
|
upgrades.Table, "telegram_version", 6,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
ctx := context.TODO()
|
||||||
|
if exists, _ := m.DB.TableExists(ctx, "telegram_file_old"); exists {
|
||||||
|
exerrors.Must(m.DB.Exec(ctx, `
|
||||||
|
PRAGMA foreign_keys = 'OFF';
|
||||||
|
DROP TABLE telegram_file_old;
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys = 'ON';
|
||||||
|
`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.InitVersion(Tag, Commit, BuildTime)
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
+19
-21
@@ -1,39 +1,37 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Define functions.
|
if [[ -z "$GID" ]]; then
|
||||||
|
GID="$UID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BINARY_NAME=/usr/bin/mautrix-telegram
|
||||||
|
|
||||||
function fixperms {
|
function fixperms {
|
||||||
chown -R $UID:$GID /data /opt/mautrix-telegram
|
chown -R $UID:$GID /data
|
||||||
|
|
||||||
|
# /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there.
|
||||||
|
if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-telegram.log" ]]; then
|
||||||
|
yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cd /opt/mautrix-telegram
|
if [[ ! -f /data/config.yaml ]]; then
|
||||||
|
$BINARY_NAME -c /data/config.yaml -e
|
||||||
# Replace database path in config.
|
|
||||||
sed -i "s#sqlite:///mautrix-telegram.db#sqlite:////data/mautrix-telegram.db#" /data/config.yaml
|
|
||||||
|
|
||||||
if [ -f /data/mx-state.json ]; then
|
|
||||||
ln -s /data/mx-state.json
|
|
||||||
fi
|
|
||||||
# Check that database is in the right state
|
|
||||||
alembic -x config=/data/config.yaml upgrade head
|
|
||||||
|
|
||||||
if [ ! -f /data/config.yaml ]; then
|
|
||||||
cp example-config.yaml /data/config.yaml
|
|
||||||
echo "Didn't find a config file."
|
echo "Didn't find a config file."
|
||||||
echo "Copied default config file to /data/config.yaml"
|
echo "Copied default config file to /data/config.yaml"
|
||||||
echo "Modify that config file to your liking."
|
echo "Modify that config file to your liking."
|
||||||
echo "Start the container again after that to generate the registration file."
|
echo "Start the container again after that to generate the registration file."
|
||||||
fixperms
|
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f /data/registration.yaml ]; then
|
if [[ ! -f /data/registration.yaml ]]; then
|
||||||
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml
|
$BINARY_NAME -g -c /data/config.yaml -r /data/registration.yaml
|
||||||
echo "Didn't find a registration file."
|
echo "Didn't find a registration file."
|
||||||
echo "Generated one for you."
|
echo "Generated one for you."
|
||||||
echo "Copy that over to synapses app service directory."
|
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
|
||||||
fixperms
|
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
cd /data
|
||||||
fixperms
|
fixperms
|
||||||
exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml
|
exec su-exec $UID:$GID $BINARY_NAME
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
# Homeserver details
|
|
||||||
homeserver:
|
|
||||||
# The address that this appservice can use to connect to the homeserver.
|
|
||||||
address: https://matrix.org
|
|
||||||
# The domain of the homeserver (for MXIDs, etc).
|
|
||||||
domain: matrix.org
|
|
||||||
# Whether or not to verify the SSL certificate of the homeserver.
|
|
||||||
# Only applies if address starts with https://
|
|
||||||
verify_ssl: true
|
|
||||||
|
|
||||||
# Application service host/registration related details
|
|
||||||
# Changing these values requires regeneration of the registration.
|
|
||||||
appservice:
|
|
||||||
# The address that the homeserver can use to connect to this appservice.
|
|
||||||
address: http://localhost:8080
|
|
||||||
|
|
||||||
# The hostname and port where this appservice should listen.
|
|
||||||
hostname: 0.0.0.0
|
|
||||||
port: 8080
|
|
||||||
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
|
|
||||||
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
|
|
||||||
max_body_size: 1
|
|
||||||
|
|
||||||
# The full URI to the database. SQLite and Postgres are fully supported.
|
|
||||||
# Other DBMSes supported by SQLAlchemy may or may not work.
|
|
||||||
database: sqlite:///mautrix-telegram.db
|
|
||||||
|
|
||||||
# Public part of web server for out-of-Matrix interaction with the bridge.
|
|
||||||
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
|
|
||||||
# the HS database.
|
|
||||||
public:
|
|
||||||
# Whether or not the public-facing endpoints should be enabled.
|
|
||||||
enabled: true
|
|
||||||
# The prefix to use in the public-facing endpoints.
|
|
||||||
prefix: /public
|
|
||||||
# The base URL where the public-facing endpoints are available. The prefix is not added
|
|
||||||
# implicitly.
|
|
||||||
external: https://example.com/public
|
|
||||||
|
|
||||||
# Provisioning API part of the web server for automated portal creation and fetching information.
|
|
||||||
# Used by things like Dimension (https://dimension.t2bot.io/).
|
|
||||||
provisioning:
|
|
||||||
# Whether or not the provisioning API should be enabled.
|
|
||||||
enabled: true
|
|
||||||
# The prefix to use in the provisioning API endpoints.
|
|
||||||
prefix: /_matrix/provision/v1
|
|
||||||
# The shared secret to authorize users of the API.
|
|
||||||
# Set to "generate" to generate and save a new token.
|
|
||||||
shared_secret: generate
|
|
||||||
|
|
||||||
# The unique ID of this appservice.
|
|
||||||
id: telegram
|
|
||||||
# Username of the appservice bot.
|
|
||||||
bot_username: telegrambot
|
|
||||||
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
|
|
||||||
# to leave display name/avatar as-is.
|
|
||||||
bot_displayname: Telegram bridge bot
|
|
||||||
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
|
|
||||||
|
|
||||||
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
|
|
||||||
as_token: "This value is generated when generating the registration"
|
|
||||||
hs_token: "This value is generated when generating the registration"
|
|
||||||
|
|
||||||
# Bridge config
|
|
||||||
bridge:
|
|
||||||
# Localpart template of MXIDs for Telegram users.
|
|
||||||
# {userid} is replaced with the user ID of the Telegram user.
|
|
||||||
username_template: "telegram_{userid}"
|
|
||||||
# Localpart template of room aliases for Telegram portal rooms.
|
|
||||||
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
|
|
||||||
alias_template: "telegram_{groupname}"
|
|
||||||
# Displayname template for Telegram users.
|
|
||||||
# {displayname} is replaced with the display name of the Telegram user.
|
|
||||||
displayname_template: "{displayname} (Telegram)"
|
|
||||||
|
|
||||||
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
|
|
||||||
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user
|
|
||||||
# ID is used.
|
|
||||||
#
|
|
||||||
# If the bridge is working properly, a phone number or an username should always be known, but
|
|
||||||
# the other one can very well be empty.
|
|
||||||
#
|
|
||||||
# Valid keys:
|
|
||||||
# "full name" (First and/or last name)
|
|
||||||
# "full name reversed" (Last and/or first name)
|
|
||||||
# "first name"
|
|
||||||
# "last name"
|
|
||||||
# "username"
|
|
||||||
# "phone number"
|
|
||||||
displayname_preference:
|
|
||||||
- full name
|
|
||||||
- username
|
|
||||||
- phone number
|
|
||||||
|
|
||||||
# Show message editing as a reply to the original message.
|
|
||||||
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
|
|
||||||
edits_as_replies: false
|
|
||||||
# Highlight changed/added parts in edits. Requires lxml.
|
|
||||||
highlight_edits: false
|
|
||||||
# Whether or not Matrix bot messages (type m.notice) should be bridged.
|
|
||||||
bridge_notices: true
|
|
||||||
# Whether to bridge Telegram bot messages as m.notices or m.texts.
|
|
||||||
bot_messages_as_notices: true
|
|
||||||
# Maximum number of members to sync per portal when starting up. Other members will be
|
|
||||||
# synced when they send messages. The maximum is 10000, after which the Telegram server
|
|
||||||
# will not send any more members.
|
|
||||||
# Defaults to no local limit (-> limited to 10000 by server)
|
|
||||||
max_initial_member_sync: -1
|
|
||||||
# Whether or not to sync the member list in channels.
|
|
||||||
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
|
|
||||||
# list regardless of this setting.
|
|
||||||
sync_channel_members: true
|
|
||||||
# The maximum number of simultaneous Telegram deletions to handle.
|
|
||||||
# A large number of simultaneous redactions could put strain on your homeserver.
|
|
||||||
max_telegram_delete: 10
|
|
||||||
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
|
|
||||||
# login website (see appservice.public config section)
|
|
||||||
allow_matrix_login: true
|
|
||||||
# Use inline images instead of m.image to make rich captions possible.
|
|
||||||
# N.B. Inline images are not supported on all clients (e.g. Riot iOS).
|
|
||||||
inline_images: false
|
|
||||||
# Whether or not to bridge plaintext highlights.
|
|
||||||
# Only enable this if your displayname_template has some static part that the bridge can use to
|
|
||||||
# reliably identify what is a plaintext highlight.
|
|
||||||
plaintext_highlights: false
|
|
||||||
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
|
|
||||||
public_portals: true
|
|
||||||
# Whether to send stickers as the new native m.sticker type or normal m.images.
|
|
||||||
# Old versions of Riot don't support the new type at all.
|
|
||||||
# Remember that proper sticker support always requires Pillow to convert webp into png.
|
|
||||||
native_stickers: true
|
|
||||||
# Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down.
|
|
||||||
# WARNING: Probably buggy, might get stuck in infinite loop.
|
|
||||||
catch_up: false
|
|
||||||
# Whether or not to use /sync to get presence, read receipts and typing notifications when using
|
|
||||||
# your own Matrix account as the Matrix puppet for your Telegram account.
|
|
||||||
sync_with_custom_puppets: true
|
|
||||||
|
|
||||||
# The formats to use when sending messages to Telegram via the relay bot.
|
|
||||||
#
|
|
||||||
# Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users.
|
|
||||||
#
|
|
||||||
# Available variables:
|
|
||||||
# $sender_displayname - The display name of the sender (e.g. Example User)
|
|
||||||
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
|
|
||||||
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
|
|
||||||
# $message - The message content as HTML
|
|
||||||
message_formats:
|
|
||||||
m.text: "<b>$sender_displayname</b>: $message"
|
|
||||||
m.emote: "* <b>$sender_displayname</b> $message"
|
|
||||||
m.file: "<b>$sender_displayname</b> sent a file: $message"
|
|
||||||
m.image: "<b>$sender_displayname</b> sent an image: $message"
|
|
||||||
m.audio: "<b>$sender_displayname</b> sent an audio file: $message"
|
|
||||||
m.video: "<b>$sender_displayname</b> sent a video: $message"
|
|
||||||
m.location: "<b>$sender_displayname</b> sent a location: $message"
|
|
||||||
|
|
||||||
# The formats to use when sending state events to Telegram via the relay bot.
|
|
||||||
#
|
|
||||||
# Variables from `message_formats` that have the `sender_` prefix are available without the prefix.
|
|
||||||
# In name_change events, `$prev_displayname` is the previous displayname.
|
|
||||||
#
|
|
||||||
# Set format to an empty string to disable the messages for that event.
|
|
||||||
state_event_formats:
|
|
||||||
join: "<b>$displayname</b> joined the room."
|
|
||||||
leave: "<b>$displayname</b> left the room."
|
|
||||||
name_change: "<b>$prev_displayname</b> changed their name to <b>$displayname</b>"
|
|
||||||
|
|
||||||
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
|
|
||||||
# `filter-mode` management commands.
|
|
||||||
#
|
|
||||||
# Filters do not affect direct chats.
|
|
||||||
# An empty blacklist will essentially disable the filter.
|
|
||||||
filter:
|
|
||||||
# Filter mode to use. Either "blacklist" or "whitelist".
|
|
||||||
# If the mode is "blacklist", the listed chats will never be bridged.
|
|
||||||
# If the mode is "whitelist", only the listed chats can be bridged.
|
|
||||||
mode: blacklist
|
|
||||||
# The list of group/channel IDs to filter.
|
|
||||||
list: []
|
|
||||||
|
|
||||||
# The prefix for commands. Only required in non-management rooms.
|
|
||||||
command_prefix: "!tg"
|
|
||||||
|
|
||||||
# Permissions for using the bridge.
|
|
||||||
# Permitted values:
|
|
||||||
# relaybot - Only use the bridge via the relaybot, no access to commands.
|
|
||||||
# user - Relaybot level + access to commands to create bridges.
|
|
||||||
# puppeting - User level + logging in with a Telegram account.
|
|
||||||
# full - Full access to use the bridge, i.e. previous levels + Matrix login.
|
|
||||||
# admin - Full access to use the bridge and some extra administration commands.
|
|
||||||
# Permitted keys:
|
|
||||||
# * - All Matrix users
|
|
||||||
# domain - All users on that homeserver
|
|
||||||
# mxid - Specific user
|
|
||||||
permissions:
|
|
||||||
"*": "relaybot"
|
|
||||||
"public.example.com": "user"
|
|
||||||
"example.com": "full"
|
|
||||||
"@admin:example.com": "admin"
|
|
||||||
|
|
||||||
# Options related to the message relay Telegram bot.
|
|
||||||
relaybot:
|
|
||||||
# Whether or not to allow creating portals from Telegram.
|
|
||||||
authless_portals: true
|
|
||||||
# Whether or not to allow Telegram group admins to use the bot commands.
|
|
||||||
whitelist_group_admins: true
|
|
||||||
# Whether or not to ignore incoming events sent by the relay bot.
|
|
||||||
ignore_own_incoming_events: true
|
|
||||||
# List of usernames/user IDs who are also allowed to use the bot commands.
|
|
||||||
whitelist:
|
|
||||||
- myusername
|
|
||||||
- 12345678
|
|
||||||
|
|
||||||
# Telegram config
|
|
||||||
telegram:
|
|
||||||
# Get your own API keys at https://my.telegram.org/apps
|
|
||||||
api_id: 12345
|
|
||||||
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
|
|
||||||
# (Optional) Create your own bot at https://t.me/BotFather
|
|
||||||
bot_token: disabled
|
|
||||||
# Telethon proxy configuration.
|
|
||||||
# You must install PySocks from pip for proxies to work.
|
|
||||||
proxy:
|
|
||||||
# Allowed types: disabled, socks4, socks5, http
|
|
||||||
type: disabled
|
|
||||||
# Proxy IP address and port.
|
|
||||||
address: 127.0.0.1
|
|
||||||
port: 1080
|
|
||||||
# Whether or not to perform DNS resolving remotely.
|
|
||||||
rdns: true
|
|
||||||
# Proxy authentication (optional).
|
|
||||||
username: ""
|
|
||||||
password: ""
|
|
||||||
|
|
||||||
# Python logging configuration.
|
|
||||||
#
|
|
||||||
# See section 16.7.2 of the Python documentation for more info:
|
|
||||||
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
|
|
||||||
logging:
|
|
||||||
version: 1
|
|
||||||
formatters:
|
|
||||||
precise:
|
|
||||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
|
||||||
handlers:
|
|
||||||
file:
|
|
||||||
class: logging.handlers.RotatingFileHandler
|
|
||||||
formatter: precise
|
|
||||||
filename: ./mautrix-telegram.log
|
|
||||||
maxBytes: 10485760
|
|
||||||
backupCount: 10
|
|
||||||
console:
|
|
||||||
class: logging.StreamHandler
|
|
||||||
formatter: precise
|
|
||||||
loggers:
|
|
||||||
mau:
|
|
||||||
level: DEBUG
|
|
||||||
telethon:
|
|
||||||
level: DEBUG
|
|
||||||
aiohttp:
|
|
||||||
level: INFO
|
|
||||||
root:
|
|
||||||
level: DEBUG
|
|
||||||
handlers: [file, console]
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
module go.mau.fi/mautrix-telegram
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
toolchain go1.26.2
|
||||||
|
|
||||||
|
tool (
|
||||||
|
go.mau.fi/util/cmd/maubuild
|
||||||
|
golang.org/x/tools/cmd/stringer
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
|
github.com/coder/websocket v1.8.14
|
||||||
|
github.com/go-faster/errors v0.7.1
|
||||||
|
github.com/go-faster/jx v1.2.0
|
||||||
|
github.com/go-faster/xor v1.0.0
|
||||||
|
github.com/go-openapi/inflect v0.21.5
|
||||||
|
github.com/gorilla/websocket v1.5.0
|
||||||
|
github.com/gotd/getdoc v0.51.0
|
||||||
|
github.com/gotd/ige v0.2.2
|
||||||
|
github.com/gotd/neo v0.1.5
|
||||||
|
github.com/gotd/tl v0.4.0
|
||||||
|
github.com/k0kubun/pp/v3 v3.5.1
|
||||||
|
github.com/klauspost/compress v1.18.5
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1
|
||||||
|
github.com/rs/zerolog v1.35.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/tidwall/gjson v1.18.0
|
||||||
|
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5
|
||||||
|
go.mau.fi/webp v0.2.0
|
||||||
|
go.mau.fi/zerozap v0.1.2
|
||||||
|
go.opentelemetry.io/otel v1.42.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.42.0
|
||||||
|
go.uber.org/atomic v1.11.0
|
||||||
|
go.uber.org/multierr v1.11.0
|
||||||
|
go.uber.org/zap v1.27.1
|
||||||
|
golang.org/x/crypto v0.50.0
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||||
|
golang.org/x/image v0.39.0
|
||||||
|
golang.org/x/net v0.53.0
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
|
golang.org/x/tools v0.44.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014
|
||||||
|
rsc.io/qr v0.2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/benbjohnson/clock v1.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/go-faster/sdk v0.28.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/lib/pq v1.12.3 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/sergi/go-diff v1.1.0 // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
|
go.mau.fi/zeroconfig v0.2.0 // indirect
|
||||||
|
go.uber.org/ratelimit v0.3.1 // indirect
|
||||||
|
golang.org/x/mod v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
|
maunium.net/go/mauflag v1.0.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
|
||||||
|
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
|
||||||
|
github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
|
||||||
|
github.com/go-faster/sdk v0.28.0 h1:zIu1bt0aeujpUJ/3GxaKy/Yn8Y5K9em4yYNsMHqOl+4=
|
||||||
|
github.com/go-faster/sdk v0.28.0/go.mod h1:Ts+Rd1B0ltePMxuuCwphkfPVtTIbJhV6jzsV46MVM5w=
|
||||||
|
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
|
||||||
|
github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
|
||||||
|
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-openapi/inflect v0.21.5 h1:M2RCq6PPS3YbIaL7CXosGL3BbzAcmfBAT0nC3YfesZA=
|
||||||
|
github.com/go-openapi/inflect v0.21.5/go.mod h1:GypUyi6bU880NYurWaEH2CmH84zFDNd+EhhmzroHmB4=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/gotd/getdoc v0.51.0 h1:69sHC34TnRCizsL/2fGWkbs88GYmEHbRHmHQxF8+umM=
|
||||||
|
github.com/gotd/getdoc v0.51.0/go.mod h1:yGFagVr+5jxhUkVTQGOqiBG4Pty0slsLGBqEYhNrGIU=
|
||||||
|
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
|
||||||
|
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
|
||||||
|
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
|
||||||
|
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
|
||||||
|
github.com/gotd/tl v0.4.0 h1:8k2z0drujiPyhpLDa9PRm/yU1Gwlfn3iUzeInPiXwMA=
|
||||||
|
github.com/gotd/tl v0.4.0/go.mod h1:CMIcjPWFS4qxxJ+1Ce7U/ilbtPrkoVo/t8uhN5Y/D7c=
|
||||||
|
github.com/k0kubun/pp/v3 v3.5.1 h1:fS8Xt0MWVVSiKwfXeIdE0WJlktdA87/gt0Hs0+j2R2s=
|
||||||
|
github.com/k0kubun/pp/v3 v3.5.1/go.mod h1:s7qPOSp65uuilpprLJs2yDi9DNd7JGyWJPtPvDFpG9w=
|
||||||
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||||
|
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
|
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
|
||||||
|
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
|
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||||
|
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||||
|
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5 h1:cNm4gkt7j907g1Q4XvyNKW8tTM8BaU91Kbfa5GGyiCs=
|
||||||
|
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
|
||||||
|
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
||||||
|
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
||||||
|
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
|
||||||
|
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
|
||||||
|
go.mau.fi/zerozap v0.1.2 h1:ffH+8kPveX1qE0IbzeBu4pJ15vwp7Sz3H13qlZ1myGs=
|
||||||
|
go.mau.fi/zerozap v0.1.2/go.mod h1:I+w0ErpJijmc7q/63ns98W1jkBqF8iXwAe1krrd1IHU=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||||
|
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||||
|
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
|
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||||
|
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
|
||||||
|
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
|
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||||
|
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||||
|
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014 h1:KwXGBWwUHYJKVTYWgbZEFcaM6uYLMvfjzHJg/TLwvKc=
|
||||||
|
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014/go.mod h1:4fZ0M0xB5ZtueQI65RilX28J/3794BeK+LaCg4U61Jk=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
__version__ = "0.3.0rc2"
|
|
||||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging.config
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from sqlalchemy import orm
|
|
||||||
import sqlalchemy as sql
|
|
||||||
|
|
||||||
from mautrix_appservice import AppService
|
|
||||||
from alchemysession import AlchemySessionContainer
|
|
||||||
|
|
||||||
from .web.provisioning import ProvisioningAPI
|
|
||||||
from .web.public import PublicBridgeWebsite
|
|
||||||
from .abstract_user import init as init_abstract_user
|
|
||||||
from .base import Base
|
|
||||||
from .bot import init as init_bot
|
|
||||||
from .config import Config
|
|
||||||
from .context import Context
|
|
||||||
from .db import init as init_db
|
|
||||||
from .formatter import init as init_formatter
|
|
||||||
from .matrix import MatrixHandler
|
|
||||||
from .portal import init as init_portal
|
|
||||||
from .puppet import init as init_puppet
|
|
||||||
from .sqlstatestore import SQLStateStore
|
|
||||||
from .user import User, init as init_user
|
|
||||||
from . import __version__
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="A Matrix-Telegram puppeting bridge.",
|
|
||||||
prog="python -m mautrix-telegram")
|
|
||||||
parser.add_argument("-c", "--config", type=str, default="config.yaml",
|
|
||||||
metavar="<path>", help="the path to your config file")
|
|
||||||
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
|
|
||||||
metavar="<path>", help="the path to the example config "
|
|
||||||
"(for automatic config updates)")
|
|
||||||
parser.add_argument("-g", "--generate-registration", action="store_true",
|
|
||||||
help="generate registration and quit")
|
|
||||||
parser.add_argument("-r", "--registration", type=str, default="registration.yaml",
|
|
||||||
metavar="<path>", help="the path to save the generated registration to")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
config = Config(args.config, args.registration, args.base_config)
|
|
||||||
config.load()
|
|
||||||
config.update()
|
|
||||||
|
|
||||||
if args.generate_registration:
|
|
||||||
config.generate_registration()
|
|
||||||
config.save()
|
|
||||||
print(f"Registration generated and saved to {config.registration_path}")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
logging.config.dictConfig(config["logging"])
|
|
||||||
log = logging.getLogger("mau.init") # type: logging.Logger
|
|
||||||
log.debug(f"Initializing mautrix-telegram {__version__}")
|
|
||||||
|
|
||||||
db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db")
|
|
||||||
db_factory = orm.sessionmaker(bind=db_engine)
|
|
||||||
db_session = orm.scoping.scoped_session(db_factory)
|
|
||||||
Base.metadata.bind = db_engine
|
|
||||||
|
|
||||||
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
|
|
||||||
table_base=Base, table_prefix="telethon_",
|
|
||||||
manage_tables=False)
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop
|
|
||||||
|
|
||||||
state_store = SQLStateStore(db_session)
|
|
||||||
mebibyte = 1024 ** 2
|
|
||||||
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
|
|
||||||
config["appservice.as_token"], config["appservice.hs_token"],
|
|
||||||
config["appservice.bot_username"], log="mau.as", loop=loop,
|
|
||||||
verify_ssl=config["homeserver.verify_ssl"], state_store=state_store,
|
|
||||||
real_user_content_key="net.maunium.telegram.puppet",
|
|
||||||
aiohttp_params={
|
|
||||||
"client_max_size": config["appservice.max_body_size"] * mebibyte
|
|
||||||
})
|
|
||||||
|
|
||||||
context = Context(appserv, db_session, config, loop, session_container)
|
|
||||||
|
|
||||||
if config["appservice.public.enabled"]:
|
|
||||||
public_website = PublicBridgeWebsite(loop)
|
|
||||||
appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app)
|
|
||||||
context.public_website = public_website
|
|
||||||
|
|
||||||
if config["appservice.provisioning.enabled"]:
|
|
||||||
provisioning_api = ProvisioningAPI(context)
|
|
||||||
appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning",
|
|
||||||
provisioning_api.app)
|
|
||||||
context.provisioning_api = provisioning_api
|
|
||||||
|
|
||||||
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
|
|
||||||
init_db(db_session)
|
|
||||||
init_abstract_user(context)
|
|
||||||
context.bot = init_bot(context)
|
|
||||||
context.mx = MatrixHandler(context)
|
|
||||||
init_formatter(context)
|
|
||||||
init_portal(context)
|
|
||||||
startup_actions = (init_puppet(context) +
|
|
||||||
init_user(context) +
|
|
||||||
[start,
|
|
||||||
context.mx.init_as_bot()])
|
|
||||||
|
|
||||||
if context.bot:
|
|
||||||
startup_actions.append(context.bot.start())
|
|
||||||
|
|
||||||
try:
|
|
||||||
log.debug("Initialization complete, running startup actions")
|
|
||||||
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
|
|
||||||
log.debug("Startup actions complete, now running forever")
|
|
||||||
loop.run_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
log.debug("Keyboard interrupt received, stopping clients")
|
|
||||||
loop.run_until_complete(
|
|
||||||
asyncio.gather(*[user.stop() for user in User.by_tgid.values()], loop=loop))
|
|
||||||
log.debug("Clients stopped, shutting down")
|
|
||||||
sys.exit(0)
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Tuple, Optional, List, Union, TYPE_CHECKING
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import platform
|
|
||||||
|
|
||||||
from sqlalchemy import orm
|
|
||||||
from telethon.tl.types import Channel, ChannelForbidden, Chat, ChatForbidden, Message, \
|
|
||||||
MessageActionChannelMigrateFrom, MessageService, PeerUser, TypeUpdate, \
|
|
||||||
UpdateChannelPinnedMessage, UpdateChatAdmins, UpdateChatParticipantAdmin, \
|
|
||||||
UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, \
|
|
||||||
UpdateDeleteMessages, UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, \
|
|
||||||
UpdateNewMessage, UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, \
|
|
||||||
UpdateUserName, UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, \
|
|
||||||
UserStatusOnline
|
|
||||||
|
|
||||||
from mautrix_appservice import MatrixRequestError, AppService
|
|
||||||
from alchemysession import AlchemySessionContainer
|
|
||||||
|
|
||||||
from . import portal as po, puppet as pu, __version__
|
|
||||||
from .db import Message as DBMessage
|
|
||||||
from .tgclient import MautrixTelegramClient
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .context import Context
|
|
||||||
from .config import Config
|
|
||||||
from .bot import Bot
|
|
||||||
|
|
||||||
config = None # type: Config
|
|
||||||
# Value updated from config in init()
|
|
||||||
MAX_DELETIONS = 10 # type: int
|
|
||||||
|
|
||||||
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
|
|
||||||
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
|
|
||||||
|
|
||||||
|
|
||||||
class AbstractUser(ABC):
|
|
||||||
session_container = None # type: AlchemySessionContainer
|
|
||||||
loop = None # type: asyncio.AbstractEventLoop
|
|
||||||
log = None # type: logging.Logger
|
|
||||||
db = None # type: orm.Session
|
|
||||||
az = None # type: AppService
|
|
||||||
bot = None # type: Bot
|
|
||||||
ignore_incoming_bot_events = True # type: bool
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.is_admin = False # type: bool
|
|
||||||
self.matrix_puppet_whitelisted = False # type: bool
|
|
||||||
self.puppet_whitelisted = False # type: bool
|
|
||||||
self.whitelisted = False # type: bool
|
|
||||||
self.relaybot_whitelisted = False # type: bool
|
|
||||||
self.client = None # type: MautrixTelegramClient
|
|
||||||
self.tgid = None # type: int
|
|
||||||
self.mxid = None # type: str
|
|
||||||
self.is_relaybot = False # type: bool
|
|
||||||
self.is_bot = False # type: bool
|
|
||||||
|
|
||||||
@property
|
|
||||||
def connected(self) -> bool:
|
|
||||||
return self.client and self.client.is_connected()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _proxy_settings(self) -> Optional[Tuple[int, str, str, str, str, str]]:
|
|
||||||
proxy_type = config["telegram.proxy.type"].lower()
|
|
||||||
if proxy_type == "disabled":
|
|
||||||
return None
|
|
||||||
elif proxy_type == "socks4":
|
|
||||||
proxy_type = 1
|
|
||||||
elif proxy_type == "socks5":
|
|
||||||
proxy_type = 2
|
|
||||||
elif proxy_type == "http":
|
|
||||||
proxy_type = 3
|
|
||||||
|
|
||||||
return (proxy_type,
|
|
||||||
config["telegram.proxy.address"], config["telegram.proxy.port"],
|
|
||||||
config["telegram.proxy.rdns"],
|
|
||||||
config["telegram.proxy.username"], config["telegram.proxy.password"])
|
|
||||||
|
|
||||||
def _init_client(self):
|
|
||||||
self.log.debug(f"Initializing client for {self.name}")
|
|
||||||
device = f"{platform.system()} {platform.release()}"
|
|
||||||
sysversion = MautrixTelegramClient.__version__
|
|
||||||
self.session = self.session_container.new_session(self.name)
|
|
||||||
self.client = MautrixTelegramClient(session=self.session,
|
|
||||||
api_id=config["telegram.api_id"],
|
|
||||||
api_hash=config["telegram.api_hash"],
|
|
||||||
loop=self.loop,
|
|
||||||
app_version=__version__,
|
|
||||||
system_version=sysversion,
|
|
||||||
device_model=device,
|
|
||||||
timeout=120,
|
|
||||||
proxy=self._proxy_settings)
|
|
||||||
self.client.add_event_handler(self._update_catch)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update(self, update: TypeUpdate) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def post_login(self):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def register_portal(self, portal: po.Portal):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def unregister_portal(self, portal: po.Portal):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
async def _update_catch(self, update: TypeUpdate):
|
|
||||||
try:
|
|
||||||
if not await self.update(update):
|
|
||||||
await self._update(update)
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Failed to handle Telegram update")
|
|
||||||
|
|
||||||
async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]:
|
|
||||||
if self.is_bot:
|
|
||||||
return []
|
|
||||||
dialogs = await self.client.get_dialogs(limit=limit)
|
|
||||||
return [dialog.entity for dialog in dialogs if (
|
|
||||||
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
|
|
||||||
and not (isinstance(dialog.entity, Chat)
|
|
||||||
and (dialog.entity.deactivated or dialog.entity.left)))]
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def name(self) -> str:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
async def is_logged_in(self) -> bool:
|
|
||||||
return self.client and await self.client.is_user_authorized()
|
|
||||||
|
|
||||||
async def has_full_access(self, allow_bot: bool = False) -> bool:
|
|
||||||
return (self.puppet_whitelisted
|
|
||||||
and (not self.is_bot or allow_bot)
|
|
||||||
and await self.is_logged_in())
|
|
||||||
|
|
||||||
async def start(self, delete_unless_authenticated: bool = False) -> "AbstractUser":
|
|
||||||
if not self.client:
|
|
||||||
self._init_client()
|
|
||||||
await self.client.connect()
|
|
||||||
self.log.debug("%s connected: %s", self.mxid, self.connected)
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def ensure_started(self, even_if_no_session=False) -> "AbstractUser":
|
|
||||||
if not self.puppet_whitelisted:
|
|
||||||
return self
|
|
||||||
self.log.debug("ensure_started(%s, connected=%s, even_if_no_session=%s, session_count=%s)",
|
|
||||||
self.mxid, self.connected, even_if_no_session,
|
|
||||||
self.session_container.Session.query.filter(
|
|
||||||
self.session_container.Session.session_id == self.mxid).count())
|
|
||||||
should_connect = (even_if_no_session or
|
|
||||||
self.session_container.Session.query.filter(
|
|
||||||
self.session_container.Session.session_id == self.mxid).count() > 0)
|
|
||||||
if not self.connected and should_connect:
|
|
||||||
await self.start(delete_unless_authenticated=not even_if_no_session)
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
await self.client.disconnect()
|
|
||||||
self.client = None
|
|
||||||
|
|
||||||
# region Telegram update handling
|
|
||||||
|
|
||||||
async def _update(self, update: TypeUpdate):
|
|
||||||
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
|
|
||||||
await self.update_message(update)
|
|
||||||
elif isinstance(update, UpdateDeleteMessages):
|
|
||||||
await self.delete_message(update)
|
|
||||||
elif isinstance(update, UpdateDeleteChannelMessages):
|
|
||||||
await self.delete_channel_message(update)
|
|
||||||
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
|
|
||||||
await self.update_typing(update)
|
|
||||||
elif isinstance(update, UpdateUserStatus):
|
|
||||||
await self.update_status(update)
|
|
||||||
elif isinstance(update, (UpdateChatAdmins, UpdateChatParticipantAdmin)):
|
|
||||||
await self.update_admin(update)
|
|
||||||
elif isinstance(update, UpdateChatParticipants):
|
|
||||||
await self.update_participants(update)
|
|
||||||
elif isinstance(update, UpdateChannelPinnedMessage):
|
|
||||||
await self.update_pinned_messages(update)
|
|
||||||
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
|
|
||||||
await self.update_others_info(update)
|
|
||||||
elif isinstance(update, UpdateReadHistoryOutbox):
|
|
||||||
await self.update_read_receipt(update)
|
|
||||||
else:
|
|
||||||
self.log.debug("Unhandled update: %s", update)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_pinned_messages(update: UpdateChannelPinnedMessage):
|
|
||||||
portal = po.Portal.get_by_tgid(update.channel_id)
|
|
||||||
if portal and portal.mxid:
|
|
||||||
await portal.receive_telegram_pin_id(update.id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_participants(update: UpdateChatParticipants):
|
|
||||||
portal = po.Portal.get_by_tgid(update.participants.chat_id)
|
|
||||||
if portal and portal.mxid:
|
|
||||||
await portal.update_telegram_participants(update.participants.participants)
|
|
||||||
|
|
||||||
async def update_read_receipt(self, update: UpdateReadHistoryOutbox):
|
|
||||||
if not isinstance(update.peer, PeerUser):
|
|
||||||
self.log.debug("Unexpected read receipt peer: %s", update.peer)
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_tgid(update.peer.user_id, self.tgid)
|
|
||||||
if not portal or not portal.mxid:
|
|
||||||
return
|
|
||||||
|
|
||||||
# We check that these are user read receipts, so tg_space is always the user ID.
|
|
||||||
message = DBMessage.query.get((update.max_id, self.tgid))
|
|
||||||
if not message:
|
|
||||||
return
|
|
||||||
|
|
||||||
puppet = pu.Puppet.get(update.peer.user_id)
|
|
||||||
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
|
||||||
|
|
||||||
async def update_admin(self, update: Union[UpdateChatAdmins, UpdateChatParticipantAdmin]):
|
|
||||||
# TODO duplication not checked
|
|
||||||
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
|
|
||||||
if isinstance(update, UpdateChatAdmins):
|
|
||||||
await portal.set_telegram_admins_enabled(update.enabled)
|
|
||||||
elif isinstance(update, UpdateChatParticipantAdmin):
|
|
||||||
await portal.set_telegram_admin(update.user_id)
|
|
||||||
else:
|
|
||||||
self.log.warning("Unexpected admin status update: %s", update)
|
|
||||||
|
|
||||||
async def update_typing(self, update: Union[UpdateUserTyping, UpdateChatUserTyping]):
|
|
||||||
if isinstance(update, UpdateUserTyping):
|
|
||||||
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
|
|
||||||
else:
|
|
||||||
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
|
|
||||||
sender = pu.Puppet.get(update.user_id)
|
|
||||||
await portal.handle_telegram_typing(sender, update)
|
|
||||||
|
|
||||||
async def update_others_info(self, update: Union[UpdateUserName, UpdateUserPhoto]):
|
|
||||||
# TODO duplication not checked
|
|
||||||
puppet = pu.Puppet.get(update.user_id)
|
|
||||||
if isinstance(update, UpdateUserName):
|
|
||||||
if await puppet.update_displayname(self, update):
|
|
||||||
puppet.save()
|
|
||||||
elif isinstance(update, UpdateUserPhoto):
|
|
||||||
if await puppet.update_avatar(self, update.photo.photo_big):
|
|
||||||
puppet.save()
|
|
||||||
else:
|
|
||||||
self.log.warning("Unexpected other user info update: %s", update)
|
|
||||||
|
|
||||||
async def update_status(self, update: UpdateUserStatus):
|
|
||||||
puppet = pu.Puppet.get(update.user_id)
|
|
||||||
if isinstance(update.status, UserStatusOnline):
|
|
||||||
await puppet.default_mxid_intent.set_presence("online")
|
|
||||||
elif isinstance(update.status, UserStatusOffline):
|
|
||||||
await puppet.default_mxid_intent.set_presence("offline")
|
|
||||||
else:
|
|
||||||
self.log.warning("Unexpected user status update: %s", update)
|
|
||||||
return
|
|
||||||
|
|
||||||
def get_message_details(self, update: UpdateMessage) -> Tuple[UpdateMessageContent,
|
|
||||||
Optional[pu.Puppet],
|
|
||||||
Optional[po.Portal]]:
|
|
||||||
if isinstance(update, UpdateShortChatMessage):
|
|
||||||
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
|
|
||||||
sender = pu.Puppet.get(update.from_id)
|
|
||||||
elif isinstance(update, UpdateShortMessage):
|
|
||||||
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
|
|
||||||
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
|
|
||||||
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
|
|
||||||
UpdateEditMessage, UpdateEditChannelMessage)):
|
|
||||||
update = update.message
|
|
||||||
if isinstance(update.to_id, PeerUser) and not update.out:
|
|
||||||
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
|
|
||||||
tg_receiver=self.tgid)
|
|
||||||
else:
|
|
||||||
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
|
|
||||||
sender = pu.Puppet.get(update.from_id) if update.from_id else None
|
|
||||||
else:
|
|
||||||
self.log.warning(
|
|
||||||
f"Unexpected message type in User#get_message_details: {type(update)}")
|
|
||||||
return update, None, None
|
|
||||||
return update, sender, portal
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _try_redact(portal: po.Portal, message: DBMessage):
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await portal.main_intent.redact(message.mx_room, message.mxid)
|
|
||||||
except MatrixRequestError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def delete_message(self, update: UpdateDeleteMessages):
|
|
||||||
if len(update.messages) > MAX_DELETIONS:
|
|
||||||
return
|
|
||||||
|
|
||||||
for message in update.messages:
|
|
||||||
message = DBMessage.query.get((message, self.tgid))
|
|
||||||
if not message:
|
|
||||||
continue
|
|
||||||
self.db.delete(message)
|
|
||||||
number_left = DBMessage.query.filter(DBMessage.mxid == message.mxid,
|
|
||||||
DBMessage.mx_room == message.mx_room).count()
|
|
||||||
if number_left == 0:
|
|
||||||
portal = po.Portal.get_by_mxid(message.mx_room)
|
|
||||||
await self._try_redact(portal, message)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
async def delete_channel_message(self, update: UpdateDeleteChannelMessages):
|
|
||||||
if len(update.messages) > MAX_DELETIONS:
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_tgid(update.channel_id)
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
|
|
||||||
for message in update.messages:
|
|
||||||
message = DBMessage.query.get((message, portal.tgid))
|
|
||||||
if not message:
|
|
||||||
continue
|
|
||||||
self.db.delete(message)
|
|
||||||
await self._try_redact(portal, message)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
async def update_message(self, original_update: UpdateMessage):
|
|
||||||
update, sender, portal = self.get_message_details(original_update)
|
|
||||||
if self.ignore_incoming_bot_events and self.bot and sender.id == self.bot.tgid:
|
|
||||||
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log)
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(update, MessageService):
|
|
||||||
if isinstance(update.action, MessageActionChannelMigrateFrom):
|
|
||||||
self.log.debug(f"Ignoring action %s to %s by %d", update.action,
|
|
||||||
portal.tgid_log,
|
|
||||||
sender.id)
|
|
||||||
return
|
|
||||||
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
|
|
||||||
sender.id)
|
|
||||||
return await portal.handle_telegram_action(self, sender, update)
|
|
||||||
|
|
||||||
user = sender.tgid if sender else "admin"
|
|
||||||
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
|
|
||||||
if config["bridge.edits_as_replies"]:
|
|
||||||
self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user)
|
|
||||||
return await portal.handle_telegram_edit(self, sender, update)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
|
|
||||||
return await portal.handle_telegram_message(self, sender, update)
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
|
|
||||||
def init(context: "Context"):
|
|
||||||
global config, MAX_DELETIONS
|
|
||||||
AbstractUser.az, AbstractUser.db, config, AbstractUser.loop, AbstractUser.relaybot = context
|
|
||||||
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
|
|
||||||
AbstractUser.session_container = context.session_container
|
|
||||||
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
Base = declarative_base() # type: declarative_base
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Awaitable, Callable, Pattern, Dict, TYPE_CHECKING
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
from telethon.tl.types import *
|
|
||||||
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
|
|
||||||
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
|
|
||||||
from telethon.errors import ChannelInvalidError, ChannelPrivateError
|
|
||||||
|
|
||||||
from .abstract_user import AbstractUser
|
|
||||||
from .db import BotChat
|
|
||||||
from . import puppet as pu, portal as po, user as u
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
config = None # type: Config
|
|
||||||
|
|
||||||
ReplyFunc = Callable[[str], Awaitable[Message]]
|
|
||||||
|
|
||||||
|
|
||||||
class Bot(AbstractUser):
|
|
||||||
log = logging.getLogger("mau.bot") # type: logging.Logger
|
|
||||||
mxid_regex = re.compile("@.+:.+") # type: Pattern
|
|
||||||
|
|
||||||
def __init__(self, token: str):
|
|
||||||
super().__init__()
|
|
||||||
self.token = token # type: str
|
|
||||||
self.puppet_whitelisted = True # type: bool
|
|
||||||
self.whitelisted = True # type: bool
|
|
||||||
self.relaybot_whitelisted = True # type: bool
|
|
||||||
self.username = None # type: str
|
|
||||||
self.is_relaybot = True # type: bool
|
|
||||||
self.is_bot = True # type: bool
|
|
||||||
self.chats = {chat.id: chat.type for chat in BotChat.query.all()} # type: Dict[int, str]
|
|
||||||
self.tg_whitelist = [] # type: List[int]
|
|
||||||
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
|
|
||||||
or False) # type: bool
|
|
||||||
|
|
||||||
async def init_permissions(self):
|
|
||||||
whitelist = config["bridge.relaybot.whitelist"] or []
|
|
||||||
for id in whitelist:
|
|
||||||
if isinstance(id, str):
|
|
||||||
entity = await self.client.get_input_entity(id)
|
|
||||||
if isinstance(entity, InputUser):
|
|
||||||
id = entity.user_id
|
|
||||||
else:
|
|
||||||
id = None
|
|
||||||
if isinstance(id, int):
|
|
||||||
self.tg_whitelist.append(id)
|
|
||||||
|
|
||||||
async def start(self, delete_unless_authenticated: bool = False) -> "Bot":
|
|
||||||
await super().start(delete_unless_authenticated)
|
|
||||||
if not await self.is_logged_in():
|
|
||||||
await self.client.sign_in(bot_token=self.token)
|
|
||||||
await self.post_login()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def post_login(self):
|
|
||||||
await self.init_permissions()
|
|
||||||
info = await self.client.get_me()
|
|
||||||
self.tgid = info.id
|
|
||||||
self.username = info.username
|
|
||||||
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
|
|
||||||
|
|
||||||
chat_ids = [id for id, type in self.chats.items() if type == "chat"]
|
|
||||||
response = await self.client(GetChatsRequest(chat_ids))
|
|
||||||
for chat in response.chats:
|
|
||||||
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
|
|
||||||
self.remove_chat(chat.id)
|
|
||||||
|
|
||||||
channel_ids = [InputChannel(id, 0)
|
|
||||||
for id, type in self.chats.items()
|
|
||||||
if type == "channel"]
|
|
||||||
for id in channel_ids:
|
|
||||||
try:
|
|
||||||
await self.client(GetChannelsRequest([id]))
|
|
||||||
except (ChannelPrivateError, ChannelInvalidError):
|
|
||||||
self.remove_chat(id.channel_id)
|
|
||||||
|
|
||||||
if config["bridge.catch_up"]:
|
|
||||||
try:
|
|
||||||
await self.client.catch_up()
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Failed to run catch_up() for bot")
|
|
||||||
|
|
||||||
def register_portal(self, portal: po.Portal):
|
|
||||||
self.add_chat(portal.tgid, portal.peer_type)
|
|
||||||
|
|
||||||
def unregister_portal(self, portal: po.Portal):
|
|
||||||
self.remove_chat(portal.tgid)
|
|
||||||
|
|
||||||
def add_chat(self, id: int, type: str):
|
|
||||||
if id not in self.chats:
|
|
||||||
self.chats[id] = type
|
|
||||||
self.db.add(BotChat(id=id, type=type))
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def remove_chat(self, id: int):
|
|
||||||
try:
|
|
||||||
del self.chats[id]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
existing_chat = BotChat.query.get(id)
|
|
||||||
if existing_chat:
|
|
||||||
self.db.delete(existing_chat)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
async def _can_use_commands(self, chat: TypePeer, tgid: int) -> bool:
|
|
||||||
if tgid in self.tg_whitelist:
|
|
||||||
return True
|
|
||||||
|
|
||||||
user = u.User.get_by_tgid(tgid)
|
|
||||||
if user and user.is_admin:
|
|
||||||
self.tg_whitelist.append(user.tgid)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.whitelist_group_admins:
|
|
||||||
if isinstance(chat, PeerChannel):
|
|
||||||
p = await self.client(GetParticipantRequest(chat, tgid))
|
|
||||||
return isinstance(p, (ChannelParticipantCreator, ChannelParticipantAdmin))
|
|
||||||
elif isinstance(chat, PeerChat):
|
|
||||||
chat = await self.client(GetFullChatRequest(chat.chat_id))
|
|
||||||
participants = chat.full_chat.participants.participants
|
|
||||||
for p in participants:
|
|
||||||
if p.user_id == tgid:
|
|
||||||
return isinstance(p, (ChatParticipantCreator, ChatParticipantAdmin))
|
|
||||||
|
|
||||||
async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool:
|
|
||||||
if not await self._can_use_commands(event.to_id, event.from_id):
|
|
||||||
await reply("You do not have the permission to use that command.")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc):
|
|
||||||
if not config["bridge.relaybot.authless_portals"]:
|
|
||||||
return await reply("This bridge doesn't allow portal creation from Telegram.")
|
|
||||||
|
|
||||||
if not portal.allow_bridging():
|
|
||||||
return await reply("This bridge doesn't allow bridging this chat.")
|
|
||||||
|
|
||||||
await portal.create_matrix_room(self)
|
|
||||||
if portal.mxid:
|
|
||||||
if portal.username:
|
|
||||||
return await reply(
|
|
||||||
f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})")
|
|
||||||
else:
|
|
||||||
return await reply(
|
|
||||||
"Portal is not public. Use `/invite <mxid>` to get an invite.")
|
|
||||||
|
|
||||||
async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc, mxid: str):
|
|
||||||
if len(mxid) == 0:
|
|
||||||
return await reply("Usage: `/invite <mxid>`")
|
|
||||||
elif not portal.mxid:
|
|
||||||
return await reply("Portal does not have Matrix room. "
|
|
||||||
"Create one with /portal first.")
|
|
||||||
if not self.mxid_regex.match(mxid):
|
|
||||||
return await reply("That doesn't look like a Matrix ID.")
|
|
||||||
user = await u.User.get_by_mxid(mxid).ensure_started()
|
|
||||||
if not user.relaybot_whitelisted:
|
|
||||||
return await reply("That user is not whitelisted to use the bridge.")
|
|
||||||
elif await user.is_logged_in():
|
|
||||||
displayname = f"@{user.username}" if user.username else user.displayname
|
|
||||||
return await reply("That user seems to be logged in. "
|
|
||||||
f"Just invite [{displayname}](tg://user?id={user.tgid})")
|
|
||||||
else:
|
|
||||||
await portal.main_intent.invite(portal.mxid, user.mxid)
|
|
||||||
return await reply(f"Invited `{user.mxid}` to the portal.")
|
|
||||||
|
|
||||||
def handle_command_id(self, message: Message, reply: ReplyFunc):
|
|
||||||
# Provide the prefixed ID to the user so that the user wouldn't need to specify whether the
|
|
||||||
# chat is a normal group or a supergroup/channel when using the ID.
|
|
||||||
if isinstance(message.to_id, PeerChannel):
|
|
||||||
return reply(f"-100{message.to_id.channel_id}")
|
|
||||||
return reply(str(-message.to_id.chat_id))
|
|
||||||
|
|
||||||
def match_command(self, text: str, command: str) -> bool:
|
|
||||||
text = text.lower()
|
|
||||||
command = f"/{command.lower()}"
|
|
||||||
command_targeted = f"{command}@{self.username.lower()}"
|
|
||||||
|
|
||||||
is_plain_command = text == command or text == command_targeted
|
|
||||||
if is_plain_command:
|
|
||||||
return True
|
|
||||||
|
|
||||||
is_arg_command = text.startswith(command + " ") or text.startswith(command_targeted + " ")
|
|
||||||
if is_arg_command:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def handle_command(self, message: Message):
|
|
||||||
def reply(reply_text):
|
|
||||||
return self.client.send_message(message.to_id, reply_text, reply_to=message.id)
|
|
||||||
|
|
||||||
text = message.message
|
|
||||||
|
|
||||||
if self.match_command(text, "id"):
|
|
||||||
return await self.handle_command_id(message, reply)
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_entity(message.to_id)
|
|
||||||
|
|
||||||
if self.match_command(text, "portal"):
|
|
||||||
if not await self.check_can_use_commands(message, reply):
|
|
||||||
return
|
|
||||||
await self.handle_command_portal(portal, reply)
|
|
||||||
elif self.match_command(text, "invite"):
|
|
||||||
if not await self.check_can_use_commands(message, reply):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
mxid = text[text.index(" ") + 1:]
|
|
||||||
except ValueError:
|
|
||||||
mxid = ""
|
|
||||||
await self.handle_command_invite(portal, reply, mxid=mxid)
|
|
||||||
|
|
||||||
def handle_service_message(self, message: MessageService):
|
|
||||||
to_id = message.to_id
|
|
||||||
if isinstance(to_id, PeerChannel):
|
|
||||||
to_id = to_id.channel_id
|
|
||||||
type = "channel"
|
|
||||||
elif isinstance(to_id, PeerChat):
|
|
||||||
to_id = to_id.chat_id
|
|
||||||
type = "chat"
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
action = message.action
|
|
||||||
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
|
|
||||||
self.add_chat(to_id, type)
|
|
||||||
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
|
|
||||||
self.remove_chat(to_id)
|
|
||||||
|
|
||||||
async def update(self, update):
|
|
||||||
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
|
|
||||||
return
|
|
||||||
if isinstance(update.message, MessageService):
|
|
||||||
return self.handle_service_message(update.message)
|
|
||||||
|
|
||||||
is_command = (isinstance(update.message, Message)
|
|
||||||
and update.message.entities and len(update.message.entities) > 0
|
|
||||||
and isinstance(update.message.entities[0], MessageEntityBotCommand))
|
|
||||||
if is_command:
|
|
||||||
return await self.handle_command(update.message)
|
|
||||||
|
|
||||||
def is_in_chat(self, peer_id) -> bool:
|
|
||||||
return peer_id in self.chats
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "bot"
|
|
||||||
|
|
||||||
|
|
||||||
def init(context) -> Optional[Bot]:
|
|
||||||
global config
|
|
||||||
config = context.config
|
|
||||||
token = config["telegram.bot_token"]
|
|
||||||
if token and not token.lower().startswith("disable"):
|
|
||||||
return Bot(token)
|
|
||||||
return None
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from .handler import (command_handler, command_handlers as _command_handlers,
|
|
||||||
CommandHandler, CommandProcessor, CommandEvent,
|
|
||||||
SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS,
|
|
||||||
SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN)
|
|
||||||
from . import clean_rooms, auth, meta, telegram, portal
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Dict
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from telethon.errors import *
|
|
||||||
|
|
||||||
from . import command_handler, CommandEvent, SECTION_AUTH
|
|
||||||
from .. import puppet as pu
|
|
||||||
from ..util import format_duration
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Check if you're logged into Telegram.")
|
|
||||||
async def ping(evt: CommandEvent):
|
|
||||||
me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
|
|
||||||
if me:
|
|
||||||
return await evt.reply(f"You're logged in as @{me.username}")
|
|
||||||
else:
|
|
||||||
return await evt.reply("You're not logged in.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Get the info of the message relay Telegram bot.")
|
|
||||||
async def ping_bot(evt: CommandEvent):
|
|
||||||
if not evt.tgbot:
|
|
||||||
return await evt.reply("Telegram message relay bot not configured.")
|
|
||||||
bot_info = await evt.tgbot.client.get_me()
|
|
||||||
mxid = pu.Puppet.get_mxid_from_id(bot_info.id)
|
|
||||||
displayname = bot_info.first_name
|
|
||||||
return await evt.reply("Telegram message relay bot is active: "
|
|
||||||
f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n"
|
|
||||||
"To use the bot, simply invite it to a portal room.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix "
|
|
||||||
"account.")
|
|
||||||
async def logout_matrix(evt: CommandEvent):
|
|
||||||
puppet = pu.Puppet.get(evt.sender.tgid)
|
|
||||||
if not puppet.is_real_user:
|
|
||||||
return await evt.reply("You are not logged in with your Matrix account.")
|
|
||||||
await puppet.switch_mxid(None, None)
|
|
||||||
await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=True, management_only=True, needs_matrix_puppeting=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Replace your Telegram account's Matrix puppet with your own Matrix "
|
|
||||||
"account")
|
|
||||||
async def login_matrix(evt: CommandEvent):
|
|
||||||
puppet = pu.Puppet.get(evt.sender.tgid)
|
|
||||||
if puppet.is_real_user:
|
|
||||||
return await evt.reply("You have already logged in with your Matrix account. "
|
|
||||||
"Log out with `$cmdprefix+sp logout-matrix` first.")
|
|
||||||
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
|
|
||||||
if allow_matrix_login:
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": enter_matrix_token,
|
|
||||||
"action": "Matrix login",
|
|
||||||
}
|
|
||||||
if evt.config["appservice.public.enabled"]:
|
|
||||||
prefix = evt.config["appservice.public.external"]
|
|
||||||
url = f"{prefix}/matrix-login?token={evt.public_website.make_token(evt.sender.mxid, '/matrix-login')}"
|
|
||||||
if allow_matrix_login:
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
|
|
||||||
"If you would like to log in within Matrix, please send your Matrix access token "
|
|
||||||
"here.\n"
|
|
||||||
f"If you would like to log in outside of Matrix, [click here]({url}).\n\n"
|
|
||||||
"Logging in outside of Matrix is recommended, because in-Matrix login would save "
|
|
||||||
"your access token in the message history.")
|
|
||||||
return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n"
|
|
||||||
f"Please visit [the login page]({url}) to log in.")
|
|
||||||
elif allow_matrix_login:
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
|
|
||||||
"Please send your Matrix access token here to log in.")
|
|
||||||
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
|
||||||
|
|
||||||
|
|
||||||
async def enter_matrix_token(evt: CommandEvent):
|
|
||||||
evt.sender.command_status = None
|
|
||||||
|
|
||||||
puppet = pu.Puppet.get(evt.sender.tgid)
|
|
||||||
if puppet.is_real_user:
|
|
||||||
return await evt.reply("You have already logged in with your Matrix account. "
|
|
||||||
"Log out with `$cmdprefix+sp logout-matrix` first.")
|
|
||||||
|
|
||||||
resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
|
|
||||||
if resp == 2:
|
|
||||||
return await evt.reply("You can only log in as your own Matrix user.")
|
|
||||||
elif resp == 1:
|
|
||||||
return await evt.reply("Failed to verify access token.")
|
|
||||||
return await evt.reply(
|
|
||||||
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, management_only=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_args="<_phone_> <_full name_>",
|
|
||||||
help_text="Register to Telegram")
|
|
||||||
async def register(evt: CommandEvent):
|
|
||||||
if await evt.sender.is_logged_in():
|
|
||||||
return await evt.reply("You are already logged in.")
|
|
||||||
elif len(evt.args) < 1:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp register <phone> <full name>`")
|
|
||||||
|
|
||||||
phone_number = evt.args[0]
|
|
||||||
if len(evt.args) == 2:
|
|
||||||
full_name = evt.args[1], ""
|
|
||||||
else:
|
|
||||||
full_name = " ".join(evt.args[1:-1]), evt.args[-1]
|
|
||||||
|
|
||||||
await request_code(evt, phone_number, {
|
|
||||||
"next": enter_code_register,
|
|
||||||
"action": "Register",
|
|
||||||
"full_name": full_name,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
async def enter_code_register(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
|
|
||||||
try:
|
|
||||||
await evt.sender.ensure_started(even_if_no_session=True)
|
|
||||||
first_name, last_name = evt.sender.command_status["full_name"]
|
|
||||||
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
|
|
||||||
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
|
|
||||||
evt.sender.command_status = None
|
|
||||||
return await evt.reply(f"Successfully registered to Telegram.")
|
|
||||||
except PhoneNumberOccupiedError:
|
|
||||||
return await evt.reply("That phone number has already been registered. "
|
|
||||||
"You can log in with `$cmdprefix+sp login`.")
|
|
||||||
except FirstNameInvalidError:
|
|
||||||
return await evt.reply("Invalid name. Please set a Matrix displayname before registering.")
|
|
||||||
except PhoneCodeExpiredError:
|
|
||||||
return await evt.reply(
|
|
||||||
"Phone code expired. Try again with `$cmdprefix+sp register <phone>`.")
|
|
||||||
except PhoneCodeInvalidError:
|
|
||||||
return await evt.reply("Invalid phone code.")
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error sending phone code")
|
|
||||||
return await evt.reply("Unhandled exception while sending code. "
|
|
||||||
"Check console for more details.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, management_only=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Get instructions on how to log in.")
|
|
||||||
async def login(evt: CommandEvent):
|
|
||||||
if await evt.sender.is_logged_in():
|
|
||||||
return await evt.reply("You are already logged in.")
|
|
||||||
|
|
||||||
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
|
|
||||||
if allow_matrix_login:
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": enter_phone_or_token,
|
|
||||||
"action": "Login",
|
|
||||||
}
|
|
||||||
|
|
||||||
if evt.config["appservice.public.enabled"]:
|
|
||||||
prefix = evt.config["appservice.public.external"]
|
|
||||||
url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}"
|
|
||||||
if allow_matrix_login:
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
|
|
||||||
"If you would like to log in within Matrix, please send your phone number or bot "
|
|
||||||
"auth token here.\n"
|
|
||||||
f"If you would like to log in outside of Matrix, [click here]({url}).\n\n"
|
|
||||||
"Logging in outside of Matrix is recommended if you have two-factor authentication "
|
|
||||||
"enabled, because in-Matrix login would save your password in the message history.")
|
|
||||||
return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n"
|
|
||||||
f"Please visit [the login page]({url}) to log in.")
|
|
||||||
elif allow_matrix_login:
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
|
|
||||||
"Please send your phone number or bot auth token here to start the login process.")
|
|
||||||
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
|
||||||
|
|
||||||
|
|
||||||
async def request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, str]):
|
|
||||||
ok = False
|
|
||||||
try:
|
|
||||||
await evt.sender.ensure_started(even_if_no_session=True)
|
|
||||||
await evt.sender.client.sign_in(phone_number)
|
|
||||||
ok = True
|
|
||||||
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
|
|
||||||
except PhoneNumberAppSignupForbiddenError:
|
|
||||||
return await evt.reply(
|
|
||||||
"Your phone number does not allow 3rd party apps to sign in.")
|
|
||||||
except PhoneNumberFloodError:
|
|
||||||
return await evt.reply(
|
|
||||||
"Your phone number has been temporarily blocked for flooding. "
|
|
||||||
"The ban is usually applied for around a day.")
|
|
||||||
except FloodWaitError as e:
|
|
||||||
return await evt.reply(
|
|
||||||
"Your phone number has been temporarily blocked for flooding. "
|
|
||||||
f"Please wait for {format_duration(e.seconds)} before trying again.")
|
|
||||||
except PhoneNumberBannedError:
|
|
||||||
return await evt.reply("Your phone number has been banned from Telegram.")
|
|
||||||
except PhoneNumberUnoccupiedError:
|
|
||||||
return await evt.reply("That phone number has not been registered. "
|
|
||||||
"Please register with `$cmdprefix+sp register <phone>`.")
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error requesting phone code")
|
|
||||||
return await evt.reply("Unhandled exception while requesting code. "
|
|
||||||
"Check console for more details.")
|
|
||||||
finally:
|
|
||||||
evt.sender.command_status = next_status if ok else None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
|
||||||
async def enter_phone_or_token(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
|
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
|
||||||
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions")
|
|
||||||
|
|
||||||
# phone numbers don't contain colons but telegram bot auth tokens do
|
|
||||||
if evt.args[0].find(":") > 0:
|
|
||||||
try:
|
|
||||||
await sign_in(evt, bot_token=evt.args[0])
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error sending auth token")
|
|
||||||
return await evt.reply("Unhandled exception while sending auth token. "
|
|
||||||
"Check console for more details.")
|
|
||||||
else:
|
|
||||||
await request_code(evt, evt.args[0], {
|
|
||||||
"next": enter_code,
|
|
||||||
"action": "Login",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
|
||||||
async def enter_code(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
|
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
|
||||||
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions")
|
|
||||||
try:
|
|
||||||
await sign_in(evt, code=evt.args[0])
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error sending phone code")
|
|
||||||
return await evt.reply("Unhandled exception while sending code. "
|
|
||||||
"Check console for more details.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
|
||||||
async def enter_password(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
|
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
|
||||||
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions")
|
|
||||||
try:
|
|
||||||
await sign_in(evt, password=" ".join(evt.args))
|
|
||||||
except AccessTokenInvalidError:
|
|
||||||
return await evt.reply("That bot token is not valid.")
|
|
||||||
except AccessTokenExpiredError:
|
|
||||||
return await evt.reply("That bot token has expired.")
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error sending password")
|
|
||||||
return await evt.reply("Unhandled exception while sending password. "
|
|
||||||
"Check console for more details.")
|
|
||||||
|
|
||||||
|
|
||||||
async def sign_in(evt: CommandEvent, **sign_in_info):
|
|
||||||
try:
|
|
||||||
await evt.sender.ensure_started(even_if_no_session=True)
|
|
||||||
user = await evt.sender.client.sign_in(**sign_in_info)
|
|
||||||
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
|
|
||||||
evt.sender.command_status = None
|
|
||||||
return await evt.reply(f"Successfully logged in as @{user.username}")
|
|
||||||
except PhoneCodeExpiredError:
|
|
||||||
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
|
|
||||||
except PhoneCodeInvalidError:
|
|
||||||
return await evt.reply("Invalid phone code.")
|
|
||||||
except PasswordHashInvalidError:
|
|
||||||
return await evt.reply("Incorrect password.")
|
|
||||||
except SessionPasswordNeededError:
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": enter_password,
|
|
||||||
"action": "Login (password entry)",
|
|
||||||
}
|
|
||||||
return await evt.reply("Your account has two-factor authentication. "
|
|
||||||
"Please send your password here.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Log out from Telegram.")
|
|
||||||
async def logout(evt: CommandEvent):
|
|
||||||
if await evt.sender.log_out():
|
|
||||||
return await evt.reply("Logged out successfully.")
|
|
||||||
return await evt.reply("Failed to log out.")
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Tuple, List
|
|
||||||
|
|
||||||
from mautrix_appservice import MatrixRequestError, IntentAPI
|
|
||||||
|
|
||||||
from . import command_handler, CommandEvent, SECTION_ADMIN
|
|
||||||
from .. import puppet as pu, portal as po
|
|
||||||
|
|
||||||
ManagementRoomList = List[Tuple[str, str]]
|
|
||||||
RoomIDList = List[str]
|
|
||||||
|
|
||||||
|
|
||||||
async def _find_rooms(intent: IntentAPI) -> Tuple[ManagementRoomList, RoomIDList,
|
|
||||||
List["po.Portal"], List["po.Portal"]]:
|
|
||||||
management_rooms = [] # type: ManagementRoomList
|
|
||||||
unidentified_rooms = [] # type: RoomIDList
|
|
||||||
portals = [] # type: List[po.Portal]
|
|
||||||
empty_portals = [] # type: List[po.Portal]
|
|
||||||
|
|
||||||
rooms = await intent.get_joined_rooms()
|
|
||||||
for room in rooms:
|
|
||||||
portal = po.Portal.get_by_mxid(room)
|
|
||||||
if not portal:
|
|
||||||
try:
|
|
||||||
members = await intent.get_room_members(room)
|
|
||||||
except MatrixRequestError:
|
|
||||||
members = []
|
|
||||||
if len(members) == 2:
|
|
||||||
other_member = members[0] if members[0] != intent.mxid else members[1]
|
|
||||||
if pu.Puppet.get_id_from_mxid(other_member):
|
|
||||||
unidentified_rooms.append(room)
|
|
||||||
else:
|
|
||||||
management_rooms.append((room, other_member))
|
|
||||||
else:
|
|
||||||
unidentified_rooms.append(room)
|
|
||||||
else:
|
|
||||||
members = await portal.get_authenticated_matrix_users()
|
|
||||||
if len(members) == 0:
|
|
||||||
empty_portals.append(portal)
|
|
||||||
else:
|
|
||||||
portals.append(portal)
|
|
||||||
|
|
||||||
return management_rooms, unidentified_rooms, portals, empty_portals
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
|
|
||||||
help_section=SECTION_ADMIN,
|
|
||||||
help_text="Clean up unused portal/management rooms.")
|
|
||||||
async def clean_rooms(evt: CommandEvent):
|
|
||||||
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
|
|
||||||
|
|
||||||
reply = ["#### Management rooms (M)"]
|
|
||||||
reply += ([f"{n+1}. [M{n+1}](https://matrix.to/#/{room}) (with {other_member}"
|
|
||||||
for n, (room, other_member) in enumerate(management_rooms)]
|
|
||||||
or ["No management rooms found."])
|
|
||||||
reply.append("#### Active portal rooms (A)")
|
|
||||||
reply += ([f"{n+1}. [P{n+1}](https://matrix.to/#/{portal.mxid}) "
|
|
||||||
f"(to Telegram chat \"{portal.title}\")"
|
|
||||||
for n, portal in enumerate(portals)]
|
|
||||||
or ["No active portal rooms found."])
|
|
||||||
reply.append("#### Unidentified rooms (U)")
|
|
||||||
reply += ([f"{n+1}. [U{n+1}](https://matrix.to/#/{room})"
|
|
||||||
for n, room in enumerate(unidentified_rooms)]
|
|
||||||
or ["No unidentified rooms found."])
|
|
||||||
reply.append("#### Inactive portal rooms (I)")
|
|
||||||
reply += ([f"{n}. [E{n}](https://matrix.to/#/{portal.mxid}) "
|
|
||||||
f"(to Telegram chat \"{portal.title}\")"
|
|
||||||
for n, portal in enumerate(empty_portals)]
|
|
||||||
or ["No inactive portal rooms found."])
|
|
||||||
|
|
||||||
reply += ["#### Usage",
|
|
||||||
("To clean the recommended set of rooms (unidentified & inactive portals), "
|
|
||||||
"type `$cmdprefix+sp clean-recommended`"),
|
|
||||||
"",
|
|
||||||
("To clean other groups of rooms, type `$cmdprefix+sp clean-groups <letters>` "
|
|
||||||
"where `letters` are the first letters of the group names (M, A, U, I)"),
|
|
||||||
"",
|
|
||||||
("To clean specific rooms, type `$cmdprefix+sp clean-range <range>` "
|
|
||||||
"where `range` is the range (e.g. `5-21`) prefixed with the first letter of"
|
|
||||||
"the group name."),
|
|
||||||
"",
|
|
||||||
("Please note that you will have to re-run `$cmdprefix+sp clean-rooms` "
|
|
||||||
"between each use of the commands above.")]
|
|
||||||
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": lambda clean_evt: set_rooms_to_clean(clean_evt, management_rooms,
|
|
||||||
unidentified_rooms, portals, empty_portals),
|
|
||||||
"action": "Room cleaning",
|
|
||||||
}
|
|
||||||
|
|
||||||
return await evt.reply("\n".join(reply))
|
|
||||||
|
|
||||||
|
|
||||||
async def set_rooms_to_clean(evt, management_rooms: ManagementRoomList,
|
|
||||||
unidentified_rooms: RoomIDList, portals: List["po.Portal"],
|
|
||||||
empty_portals: List["po.Portal"]):
|
|
||||||
command = evt.args[0]
|
|
||||||
rooms_to_clean = []
|
|
||||||
if command == "clean-recommended":
|
|
||||||
rooms_to_clean = empty_portals + unidentified_rooms
|
|
||||||
elif command == "clean-groups":
|
|
||||||
if len(evt.args) < 2:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp clean-groups [M][A][U][I]")
|
|
||||||
groups_to_clean = evt.args[1]
|
|
||||||
if "M" in groups_to_clean:
|
|
||||||
rooms_to_clean += [room_id for (room_id, user_id) in management_rooms]
|
|
||||||
if "A" in groups_to_clean:
|
|
||||||
rooms_to_clean += portals
|
|
||||||
if "U" in groups_to_clean:
|
|
||||||
rooms_to_clean += unidentified_rooms
|
|
||||||
if "I" in groups_to_clean:
|
|
||||||
rooms_to_clean += empty_portals
|
|
||||||
elif command == "clean-range":
|
|
||||||
try:
|
|
||||||
range = evt.args[1]
|
|
||||||
group, range = range[0], range[1:]
|
|
||||||
start, end = range.split("-")
|
|
||||||
start, end = int(start), int(end)
|
|
||||||
if group == "M":
|
|
||||||
group = [room_id for (room_id, user_id) in management_rooms]
|
|
||||||
elif group == "A":
|
|
||||||
group = portals
|
|
||||||
elif group == "U":
|
|
||||||
group = unidentified_rooms
|
|
||||||
elif group == "I":
|
|
||||||
group = empty_portals
|
|
||||||
else:
|
|
||||||
raise ValueError("Unknown group")
|
|
||||||
rooms_to_clean = group[start - 1:end]
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
return await evt.reply(
|
|
||||||
"**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_><range>")
|
|
||||||
else:
|
|
||||||
return await evt.reply(f"Unknown room cleaning action `{command}`. "
|
|
||||||
"Use `$cmdprefix+sp cancel` to cancel room "
|
|
||||||
"cleaning.")
|
|
||||||
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean),
|
|
||||||
"action": "Room cleaning",
|
|
||||||
}
|
|
||||||
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type"
|
|
||||||
"`$cmdprefix+sp confirm-clean`.")
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_room_cleanup(evt, rooms_to_clean):
|
|
||||||
if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
|
|
||||||
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
|
|
||||||
"This might take a while.")
|
|
||||||
cleaned = 0
|
|
||||||
for room in rooms_to_clean:
|
|
||||||
if isinstance(room, po.Portal):
|
|
||||||
await room.cleanup_and_delete()
|
|
||||||
cleaned += 1
|
|
||||||
elif isinstance(room, str):
|
|
||||||
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
|
|
||||||
cleaned += 1
|
|
||||||
evt.sender.command_status = None
|
|
||||||
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
|
|
||||||
else:
|
|
||||||
await evt.reply("Room cleaning cancelled.")
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import List, Dict, Callable, Optional
|
|
||||||
from collections import namedtuple
|
|
||||||
import markdown
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from telethon.errors import FloodWaitError
|
|
||||||
|
|
||||||
from ..util import format_duration
|
|
||||||
from .. import user as u, context as c
|
|
||||||
|
|
||||||
command_handlers = {} # type: Dict[str, CommandHandler]
|
|
||||||
|
|
||||||
HelpSection = namedtuple("HelpSection", "name order description")
|
|
||||||
|
|
||||||
SECTION_GENERAL = HelpSection("General", 0, "")
|
|
||||||
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
|
||||||
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
|
|
||||||
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
|
|
||||||
SECTION_MISC = HelpSection("Miscellaneous", 40, "")
|
|
||||||
SECTION_ADMIN = HelpSection("Administration", 50, "")
|
|
||||||
|
|
||||||
|
|
||||||
class CommandEvent:
|
|
||||||
def __init__(self, processor: "CommandProcessor", room: str, sender: u.User, command: str,
|
|
||||||
args: List[str], is_management: bool, is_portal: bool):
|
|
||||||
self.az = processor.az
|
|
||||||
self.log = processor.log
|
|
||||||
self.loop = processor.loop
|
|
||||||
self.tgbot = processor.tgbot
|
|
||||||
self.config = processor.config
|
|
||||||
self.public_website = processor.public_website
|
|
||||||
self.command_prefix = processor.command_prefix
|
|
||||||
self.room_id = room
|
|
||||||
self.sender = sender
|
|
||||||
self.command = command
|
|
||||||
self.args = args
|
|
||||||
self.is_management = is_management
|
|
||||||
self.is_portal = is_portal
|
|
||||||
|
|
||||||
def reply(self, message: str, allow_html: bool = False, render_markdown: bool = True):
|
|
||||||
message = message.replace("$cmdprefix+sp ",
|
|
||||||
"" if self.is_management else f"{self.command_prefix} ")
|
|
||||||
message = message.replace("$cmdprefix", self.command_prefix)
|
|
||||||
html = None
|
|
||||||
if render_markdown:
|
|
||||||
html = markdown.markdown(message, safe_mode="escape" if allow_html else False)
|
|
||||||
elif allow_html:
|
|
||||||
html = message
|
|
||||||
return self.az.intent.send_notice(self.room_id, message, html=html)
|
|
||||||
|
|
||||||
|
|
||||||
class CommandHandler:
|
|
||||||
def __init__(self, handler: Callable[[CommandEvent], None], needs_auth: bool,
|
|
||||||
needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool,
|
|
||||||
management_only: bool, name: str, help_text: str, help_args: str,
|
|
||||||
help_section: HelpSection):
|
|
||||||
self._handler = handler
|
|
||||||
self.needs_auth = needs_auth
|
|
||||||
self.needs_puppeting = needs_puppeting
|
|
||||||
self.needs_matrix_puppeting = needs_matrix_puppeting
|
|
||||||
self.needs_admin = needs_admin
|
|
||||||
self.management_only = management_only
|
|
||||||
self.name = name
|
|
||||||
self._help_text = help_text
|
|
||||||
self._help_args = help_args
|
|
||||||
self.help_section = help_section
|
|
||||||
|
|
||||||
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
|
|
||||||
if self.management_only and not evt.is_management:
|
|
||||||
return (f"`{evt.command}` is a restricted command: "
|
|
||||||
"you may only run it in management rooms.")
|
|
||||||
elif self.needs_puppeting and not evt.sender.puppet_whitelisted:
|
|
||||||
return "This command requires puppeting privileges."
|
|
||||||
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
|
|
||||||
return "This command requires Matrix puppeting privileges."
|
|
||||||
elif self.needs_admin and not evt.sender.is_admin:
|
|
||||||
return "This command requires administrator privileges."
|
|
||||||
elif self.needs_auth and not await evt.sender.is_logged_in():
|
|
||||||
return "This command requires you to be logged in."
|
|
||||||
return None
|
|
||||||
|
|
||||||
def has_permission(self, is_management: bool, puppet_whitelisted: bool,
|
|
||||||
matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool:
|
|
||||||
return ((not self.management_only or is_management) and
|
|
||||||
(not self.needs_puppeting or puppet_whitelisted) and
|
|
||||||
(not self.needs_matrix_puppeting or matrix_puppet_whitelisted) and
|
|
||||||
(not self.needs_admin or is_admin) and
|
|
||||||
(not self.needs_auth or is_logged_in))
|
|
||||||
|
|
||||||
async def __call__(self, evt: CommandEvent):
|
|
||||||
error = await self.get_permission_error(evt)
|
|
||||||
if error is not None:
|
|
||||||
return await evt.reply(error)
|
|
||||||
return await self._handler(evt)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_help(self) -> bool:
|
|
||||||
return bool(self.help_section) and bool(self._help_text)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def help(self) -> str:
|
|
||||||
return f"**{self.name}** {self._help_args} - {self._help_text}"
|
|
||||||
|
|
||||||
|
|
||||||
def command_handler(_func: Optional[Callable[[CommandEvent], None]] = None, *, needs_auth=True,
|
|
||||||
needs_puppeting=True, needs_matrix_puppeting=False, needs_admin=False,
|
|
||||||
management_only=False, name=None, help_text="", help_args="",
|
|
||||||
help_section=None):
|
|
||||||
input_name = name
|
|
||||||
|
|
||||||
def decorator(func: Callable[[CommandEvent], None]):
|
|
||||||
name = input_name or func.__name__.replace("_", "-")
|
|
||||||
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting,
|
|
||||||
needs_admin, management_only, name, help_text, help_args,
|
|
||||||
help_section)
|
|
||||||
command_handlers[handler.name] = handler
|
|
||||||
return handler
|
|
||||||
|
|
||||||
return decorator if _func is None else decorator(_func)
|
|
||||||
|
|
||||||
|
|
||||||
class CommandProcessor:
|
|
||||||
log = logging.getLogger("mau.commands")
|
|
||||||
|
|
||||||
def __init__(self, context: c.Context):
|
|
||||||
self.az, self.db, self.config, self.loop, self.tgbot = context
|
|
||||||
self.public_website = context.public_website
|
|
||||||
self.command_prefix = self.config["bridge.command_prefix"]
|
|
||||||
|
|
||||||
async def handle(self, room: str, sender: u.User, command: str, args: List[str],
|
|
||||||
is_management: bool, is_portal: bool):
|
|
||||||
evt = CommandEvent(self, room, sender, command, args, is_management, is_portal)
|
|
||||||
orig_command = command
|
|
||||||
command = command.lower()
|
|
||||||
try:
|
|
||||||
command = command_handlers[command]
|
|
||||||
except KeyError:
|
|
||||||
if sender.command_status and "next" in sender.command_status:
|
|
||||||
args.insert(0, orig_command)
|
|
||||||
evt.command = ""
|
|
||||||
command = sender.command_status["next"]
|
|
||||||
else:
|
|
||||||
command = command_handlers["unknown-command"]
|
|
||||||
try:
|
|
||||||
await command(evt)
|
|
||||||
except FloodWaitError as e:
|
|
||||||
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Unhandled error while handling command "
|
|
||||||
f"{evt.command} {' '.join(args)} from {sender.mxid}")
|
|
||||||
return await evt.reply("Unhandled error while handling command. "
|
|
||||||
"Check logs for more details.")
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from . import command_handler, CommandEvent, _command_handlers, SECTION_GENERAL
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
|
||||||
help_section=SECTION_GENERAL,
|
|
||||||
help_text="Cancel an ongoing action (such as login)")
|
|
||||||
def cancel(evt: CommandEvent):
|
|
||||||
if evt.sender.command_status:
|
|
||||||
action = evt.sender.command_status["action"]
|
|
||||||
evt.sender.command_status = None
|
|
||||||
return evt.reply(f"{action} cancelled.")
|
|
||||||
else:
|
|
||||||
return evt.reply("No ongoing command.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, needs_puppeting=False)
|
|
||||||
def unknown_command(evt: CommandEvent):
|
|
||||||
return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
|
|
||||||
|
|
||||||
|
|
||||||
help_cache = {}
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_help_text(evt: CommandEvent):
|
|
||||||
cache_key = (evt.is_management, evt.sender.puppet_whitelisted,
|
|
||||||
evt.sender.matrix_puppet_whitelisted, evt.sender.is_admin,
|
|
||||||
await evt.sender.is_logged_in())
|
|
||||||
if cache_key not in help_cache:
|
|
||||||
help = {}
|
|
||||||
for handler in _command_handlers.values():
|
|
||||||
if handler.has_help and handler.has_permission(*cache_key):
|
|
||||||
help.setdefault(handler.help_section, [])
|
|
||||||
help[handler.help_section].append(handler.help + " ")
|
|
||||||
help = sorted(help.items(), key=lambda item: item[0].order)
|
|
||||||
help = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help]
|
|
||||||
help_cache[cache_key] = "\n".join(help)
|
|
||||||
return help_cache[cache_key]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_management_status(evt: CommandEvent):
|
|
||||||
if evt.is_management:
|
|
||||||
return "This is a management room: prefixing commands with `$cmdprefix` is not required."
|
|
||||||
elif evt.is_portal:
|
|
||||||
return ("**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n"
|
|
||||||
"Management commands will not be sent to Telegram.")
|
|
||||||
return "**This is not a management room**: you must prefix commands with `$cmdprefix`."
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
|
||||||
help_section=SECTION_GENERAL,
|
|
||||||
help_text="Show this help message.")
|
|
||||||
async def help(evt: CommandEvent):
|
|
||||||
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
|
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, Callable
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from telethon.errors import *
|
|
||||||
from telethon.tl.types import ChatForbidden, ChannelForbidden
|
|
||||||
from mautrix_appservice import MatrixRequestError, IntentAPI
|
|
||||||
|
|
||||||
from .. import portal as po, user as u
|
|
||||||
from . import (command_handler, CommandEvent,
|
|
||||||
SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_admin=True, needs_auth=False, name="set-pl",
|
|
||||||
help_section=SECTION_ADMIN,
|
|
||||||
help_args="<_level_> [_mxid_]",
|
|
||||||
help_text="Set a temporary power level without affecting Telegram.")
|
|
||||||
async def set_power_level(evt: CommandEvent):
|
|
||||||
try:
|
|
||||||
level = int(evt.args[0])
|
|
||||||
except KeyError:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp set-power <level> [mxid]`")
|
|
||||||
except ValueError:
|
|
||||||
return await evt.reply("The level must be an integer.")
|
|
||||||
levels = await evt.az.intent.get_power_levels(evt.room_id)
|
|
||||||
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
|
|
||||||
levels["users"][mxid] = level
|
|
||||||
try:
|
|
||||||
await evt.az.intent.set_power_levels(evt.room_id, levels)
|
|
||||||
except MatrixRequestError:
|
|
||||||
evt.log.exception("Failed to set power level.")
|
|
||||||
return await evt.reply("Failed to set power level.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="Get a Telegram invite link to the current chat.")
|
|
||||||
async def invite_link(evt: CommandEvent):
|
|
||||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
|
|
||||||
if portal.peer_type == "user":
|
|
||||||
return await evt.reply("You can't invite users to private chats.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
link = await portal.get_invite_link(evt.sender)
|
|
||||||
return await evt.reply(f"Invite link to {portal.title}: {link}")
|
|
||||||
except ValueError as e:
|
|
||||||
return await evt.reply(e.args[0])
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
return await evt.reply("You don't have the permission to create an invite link.")
|
|
||||||
|
|
||||||
|
|
||||||
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50):
|
|
||||||
if sender.is_admin:
|
|
||||||
return True
|
|
||||||
# Make sure the state store contains the power levels.
|
|
||||||
try:
|
|
||||||
await intent.get_power_levels(room)
|
|
||||||
except MatrixRequestError:
|
|
||||||
return False
|
|
||||||
return intent.state_store.has_power_level(room, sender.mxid,
|
|
||||||
event=f"net.maunium.telegram.{event}",
|
|
||||||
default=default)
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
|
|
||||||
action: Optional[str] = None):
|
|
||||||
room_id = evt.args[0] if len(evt.args) > 0 else evt.room_id
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal:
|
|
||||||
that_this = "This" if room_id == evt.room_id else "That"
|
|
||||||
return await evt.reply(f"{that_this} is not a portal room."), False
|
|
||||||
|
|
||||||
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission):
|
|
||||||
action = action or f"{permission.replace('_', ' ')}s"
|
|
||||||
return await evt.reply(f"You do not have the permissions to {action} that portal."), False
|
|
||||||
return portal, True
|
|
||||||
|
|
||||||
|
|
||||||
def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
|
|
||||||
completed_message: str):
|
|
||||||
async def post_confirm(confirm):
|
|
||||||
confirm.sender.command_status = None
|
|
||||||
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
|
|
||||||
await function()
|
|
||||||
if confirm.room_id != room_id:
|
|
||||||
return await confirm.reply(completed_message)
|
|
||||||
else:
|
|
||||||
return await confirm.reply(f"{action} cancelled.")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"next": post_confirm,
|
|
||||||
"action": action,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="Remove all users from the current portal room and forget the portal. "
|
|
||||||
"Only works for group chats; to delete a private chat portal, simply "
|
|
||||||
"leave the room.")
|
|
||||||
async def delete_portal(evt: CommandEvent):
|
|
||||||
portal, ok = await _get_portal_and_check_permission(evt, "unbridge")
|
|
||||||
if not ok:
|
|
||||||
return
|
|
||||||
|
|
||||||
evt.sender.command_status = _get_portal_murder_function("Portal deletion", portal.mxid,
|
|
||||||
portal.cleanup_and_delete, "delete",
|
|
||||||
"Portal successfully deleted.")
|
|
||||||
return await evt.reply("Please confirm deletion of portal "
|
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
|
|
||||||
f"to Telegram chat \"{portal.title}\" "
|
|
||||||
"by typing `$cmdprefix+sp confirm-delete`"
|
|
||||||
"\n\n"
|
|
||||||
"**WARNING:** If the bridge bot has the power level to do so, **this "
|
|
||||||
"will kick ALL users** in the room. If you just want to remove the "
|
|
||||||
"bridge, use `$cmdprefix+sp unbridge` instead.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="Remove puppets from the current portal room and forget the portal.")
|
|
||||||
async def unbridge(evt: CommandEvent):
|
|
||||||
portal, ok = await _get_portal_and_check_permission(evt, "unbridge")
|
|
||||||
if not ok:
|
|
||||||
return
|
|
||||||
|
|
||||||
evt.sender.command_status = _get_portal_murder_function("Room unbridging", portal.mxid,
|
|
||||||
portal.unbridge, "unbridge",
|
|
||||||
"Room successfully unbridged.")
|
|
||||||
return await evt.reply(f"Please confirm unbridging chat \"{portal.title}\" from room "
|
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
|
|
||||||
"by typing `$cmdprefix+sp confirm-unbridge`")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_args="[_id_]",
|
|
||||||
help_text="Bridge the current Matrix room to the Telegram chat with the given "
|
|
||||||
"ID. The ID must be the prefixed version that you get with the `/id` "
|
|
||||||
"command of the Telegram-side bot.")
|
|
||||||
async def bridge(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** "
|
|
||||||
"`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`")
|
|
||||||
room_id = evt.args[1] if len(evt.args) > 1 else evt.room_id
|
|
||||||
that_this = "This" if room_id == evt.room_id else "That"
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if portal:
|
|
||||||
return await evt.reply(f"{that_this} room is already a portal room.")
|
|
||||||
|
|
||||||
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
|
|
||||||
return await evt.reply(f"You do not have the permissions to bridge {that_this} room.")
|
|
||||||
|
|
||||||
# The /id bot command provides the prefixed ID, so we assume
|
|
||||||
tgid = evt.args[0]
|
|
||||||
if tgid.startswith("-100"):
|
|
||||||
tgid = int(tgid[4:])
|
|
||||||
peer_type = "channel"
|
|
||||||
elif tgid.startswith("-"):
|
|
||||||
tgid = -int(tgid)
|
|
||||||
peer_type = "chat"
|
|
||||||
else:
|
|
||||||
return await evt.reply("That doesn't seem like a prefixed Telegram chat ID.\n\n"
|
|
||||||
"If you did not get the ID using the `/id` bot command, please "
|
|
||||||
"prefix channel IDs with `-100` and normal group IDs with `-`.\n\n"
|
|
||||||
"Bridging private chats to existing rooms is not allowed.")
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type)
|
|
||||||
if not portal.allow_bridging():
|
|
||||||
return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n"
|
|
||||||
"If you're the bridge admin, try "
|
|
||||||
"`$cmdprefix+sp whitelist <Telegram chat ID>` first.")
|
|
||||||
if portal.mxid:
|
|
||||||
has_portal_message = (
|
|
||||||
"That Telegram chat already has a portal at "
|
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ")
|
|
||||||
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
|
|
||||||
return await evt.reply(f"{has_portal_message}"
|
|
||||||
"Additionally, you do not have the permissions to unbridge "
|
|
||||||
"that room.")
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": confirm_bridge,
|
|
||||||
"action": "Room bridging",
|
|
||||||
"mxid": portal.mxid,
|
|
||||||
"bridge_to_mxid": room_id,
|
|
||||||
"tgid": portal.tgid,
|
|
||||||
"peer_type": portal.peer_type,
|
|
||||||
}
|
|
||||||
return await evt.reply(f"{has_portal_message}"
|
|
||||||
"However, you have the permissions to unbridge that room.\n\n"
|
|
||||||
"To delete that portal completely and continue bridging, use "
|
|
||||||
"`$cmdprefix+sp delete-and-continue`. To unbridge the portal "
|
|
||||||
"without kicking Matrix users, use `$cmdprefix+sp unbridge-and-"
|
|
||||||
"continue`. To cancel, use `$cmdprefix+sp cancel`")
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": confirm_bridge,
|
|
||||||
"action": "Room bridging",
|
|
||||||
"bridge_to_mxid": room_id,
|
|
||||||
"tgid": portal.tgid,
|
|
||||||
"peer_type": portal.peer_type,
|
|
||||||
}
|
|
||||||
return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the "
|
|
||||||
"chat to this room, use `$cmdprefix+sp continue`")
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"):
|
|
||||||
if not portal.mxid:
|
|
||||||
await evt.reply("The portal seems to have lost its Matrix room between you"
|
|
||||||
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
|
||||||
"Continuing without touching previous Matrix room...")
|
|
||||||
return True, None
|
|
||||||
elif evt.args[0] == "delete-and-continue":
|
|
||||||
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
|
|
||||||
message="Portal deleted (moving to another room)")
|
|
||||||
elif evt.args[0] == "unbridge-and-continue":
|
|
||||||
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
|
|
||||||
message="Room unbridged (portal moving to another room)",
|
|
||||||
puppets_only=True)
|
|
||||||
else:
|
|
||||||
await evt.reply(
|
|
||||||
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
|
|
||||||
"Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-"
|
|
||||||
"continue` to either delete or unbridge the existing room (respectively) and "
|
|
||||||
"continue with the bridging.\n\n"
|
|
||||||
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel.")
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
|
|
||||||
async def confirm_bridge(evt: CommandEvent):
|
|
||||||
status = evt.sender.command_status
|
|
||||||
try:
|
|
||||||
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
|
|
||||||
bridge_to_mxid = status["bridge_to_mxid"]
|
|
||||||
except KeyError:
|
|
||||||
evt.sender.command_status = None
|
|
||||||
return await evt.reply("Fatal error: tgid or peer_type missing from command_status. "
|
|
||||||
"This shouldn't happen unless you're messing with the command "
|
|
||||||
"handler code.")
|
|
||||||
if "mxid" in status:
|
|
||||||
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
|
|
||||||
if not ok:
|
|
||||||
return
|
|
||||||
elif coro:
|
|
||||||
asyncio.ensure_future(coro, loop=evt.loop)
|
|
||||||
await evt.reply("Cleaning up previous portal room...")
|
|
||||||
elif portal.mxid:
|
|
||||||
evt.sender.command_status = None
|
|
||||||
return await evt.reply("The portal seems to have created a Matrix room between you "
|
|
||||||
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
|
||||||
"Please start over by calling the bridge command again.")
|
|
||||||
elif evt.args[0] != "continue":
|
|
||||||
return await evt.reply("Please use `$cmdprefix+sp continue` to confirm the bridging or "
|
|
||||||
"`$cmdprefix+sp cancel` to cancel.")
|
|
||||||
|
|
||||||
is_logged_in = await evt.sender.is_logged_in()
|
|
||||||
user = evt.sender if is_logged_in else evt.tgbot
|
|
||||||
try:
|
|
||||||
entity = await user.client.get_entity(portal.peer)
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
|
|
||||||
if is_logged_in:
|
|
||||||
return await evt.reply("Failed to get info of telegram chat. "
|
|
||||||
"You are logged in, are you in that chat?")
|
|
||||||
else:
|
|
||||||
return await evt.reply("Failed to get info of telegram chat. "
|
|
||||||
"You're not logged in, is the relay bot in the chat?")
|
|
||||||
if isinstance(entity, (ChatForbidden, ChannelForbidden)):
|
|
||||||
if is_logged_in:
|
|
||||||
return await evt.reply("You don't seem to be in that chat.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("The bot doesn't seem to be in that chat.")
|
|
||||||
|
|
||||||
direct = False
|
|
||||||
|
|
||||||
portal.mxid = bridge_to_mxid
|
|
||||||
portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id)
|
|
||||||
portal.photo_id = ""
|
|
||||||
portal.save()
|
|
||||||
|
|
||||||
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
|
|
||||||
loop=evt.loop)
|
|
||||||
|
|
||||||
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_initial_state(intent: IntentAPI, room_id: str):
|
|
||||||
state = await intent.get_room_state(room_id)
|
|
||||||
title = None
|
|
||||||
about = None
|
|
||||||
levels = None
|
|
||||||
for event in state:
|
|
||||||
try:
|
|
||||||
if event["type"] == "m.room.name":
|
|
||||||
title = event["content"]["name"]
|
|
||||||
elif event["type"] == "m.room.topic":
|
|
||||||
about = event["content"]["topic"]
|
|
||||||
elif event["type"] == "m.room.power_levels":
|
|
||||||
levels = event["content"]
|
|
||||||
elif event["type"] == "m.room.canonical_alias":
|
|
||||||
title = title or event["content"]["alias"]
|
|
||||||
except KeyError:
|
|
||||||
# Some state event probably has empty content
|
|
||||||
pass
|
|
||||||
return title, about, levels
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="[_type_]",
|
|
||||||
help_text="Create a Telegram chat of the given type for the current Matrix room. "
|
|
||||||
"The type is either `group`, `supergroup` or `channel` (defaults to "
|
|
||||||
"`group`).")
|
|
||||||
async def create(evt: CommandEvent):
|
|
||||||
type = evt.args[0] if len(evt.args) > 0 else "group"
|
|
||||||
if type not in {"chat", "group", "supergroup", "channel"}:
|
|
||||||
return await evt.reply(
|
|
||||||
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
|
|
||||||
|
|
||||||
if po.Portal.get_by_mxid(evt.room_id):
|
|
||||||
return await evt.reply("This is already a portal room.")
|
|
||||||
|
|
||||||
if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
|
|
||||||
return await evt.reply("You do not have the permissions to bridge this room.")
|
|
||||||
|
|
||||||
title, about, levels = await get_initial_state(evt.az.intent, evt.room_id)
|
|
||||||
if not title:
|
|
||||||
return await evt.reply("Please set a title before creating a Telegram chat.")
|
|
||||||
|
|
||||||
supergroup = type == "supergroup"
|
|
||||||
type = {
|
|
||||||
"supergroup": "channel",
|
|
||||||
"channel": "channel",
|
|
||||||
"chat": "chat",
|
|
||||||
"group": "chat",
|
|
||||||
}[type]
|
|
||||||
|
|
||||||
portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type)
|
|
||||||
try:
|
|
||||||
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
|
|
||||||
except ValueError as e:
|
|
||||||
portal.delete()
|
|
||||||
return await evt.reply(e.args[0])
|
|
||||||
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="Upgrade a normal Telegram group to a supergroup.")
|
|
||||||
async def upgrade(evt: CommandEvent):
|
|
||||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
elif portal.peer_type == "channel":
|
|
||||||
return await evt.reply("This is already a supergroup or a channel.")
|
|
||||||
elif portal.peer_type == "user":
|
|
||||||
return await evt.reply("You can't upgrade private chats.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await portal.upgrade_telegram_chat(evt.sender)
|
|
||||||
return await evt.reply(f"Group upgraded to supergroup. New ID: {portal.tgid}")
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
return await evt.reply("You don't have the permission to upgrade this group.")
|
|
||||||
except ValueError as e:
|
|
||||||
return await evt.reply(e.args[0])
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_args="<_name_|`-`>",
|
|
||||||
help_text="Change the username of a supergroup/channel. "
|
|
||||||
"To disable, use a dash (`-`) as the name.")
|
|
||||||
async def group_name(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
elif portal.peer_type != "channel":
|
|
||||||
return await evt.reply("Only channels and supergroups have usernames.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await portal.set_telegram_username(evt.sender,
|
|
||||||
evt.args[0] if evt.args[0] != "-" else "")
|
|
||||||
if portal.username:
|
|
||||||
return await evt.reply(f"Username of channel changed to {portal.username}.")
|
|
||||||
else:
|
|
||||||
return await evt.reply(f"Channel is now private.")
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
return await evt.reply(
|
|
||||||
"You don't have the permission to set the username of this channel.")
|
|
||||||
except UsernameNotModifiedError:
|
|
||||||
if portal.username:
|
|
||||||
return await evt.reply("That is already the username of this channel.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("This channel is already private")
|
|
||||||
except UsernameOccupiedError:
|
|
||||||
return await evt.reply("That username is already in use.")
|
|
||||||
except UsernameInvalidError:
|
|
||||||
return await evt.reply("Invalid username")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_admin=True,
|
|
||||||
help_section=SECTION_ADMIN,
|
|
||||||
help_args="<`whitelist`|`blacklist`>",
|
|
||||||
help_text="Change whether the bridge will allow or disallow bridging rooms by "
|
|
||||||
"default.")
|
|
||||||
async def filter_mode(evt: CommandEvent):
|
|
||||||
try:
|
|
||||||
mode = evt.args[0]
|
|
||||||
if mode not in ("whitelist", "blacklist"):
|
|
||||||
raise ValueError()
|
|
||||||
except (IndexError, ValueError):
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode <whitelist/blacklist>`")
|
|
||||||
|
|
||||||
evt.config["bridge.filter.mode"] = mode
|
|
||||||
evt.config.save()
|
|
||||||
po.Portal.filter_mode = mode
|
|
||||||
if mode == "whitelist":
|
|
||||||
return await evt.reply("The bridge will now disallow bridging chats by default.\n"
|
|
||||||
"To allow bridging a specific chat, use"
|
|
||||||
"`!filter whitelist <chat ID>`.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("The bridge will now allow bridging chats by default.\n"
|
|
||||||
"To disallow bridging a specific chat, use"
|
|
||||||
"`!filter blacklist <chat ID>`.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_admin=True,
|
|
||||||
help_section=SECTION_ADMIN,
|
|
||||||
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
|
|
||||||
help_text="Allow or disallow bridging a specific chat.")
|
|
||||||
async def filter(evt: CommandEvent):
|
|
||||||
try:
|
|
||||||
action = evt.args[0]
|
|
||||||
if action not in ("whitelist", "blacklist", "add", "remove"):
|
|
||||||
raise ValueError()
|
|
||||||
|
|
||||||
id = evt.args[1]
|
|
||||||
if id.startswith("-100"):
|
|
||||||
id = int(id[4:])
|
|
||||||
elif id.startswith("-"):
|
|
||||||
id = int(id[1:])
|
|
||||||
else:
|
|
||||||
id = int(id)
|
|
||||||
except (IndexError, ValueError):
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
|
|
||||||
|
|
||||||
mode = evt.config["bridge.filter.mode"]
|
|
||||||
if mode not in ("blacklist", "whitelist"):
|
|
||||||
return await evt.reply(f"Unknown filter mode \"{mode}\". Please fix the bridge config.")
|
|
||||||
|
|
||||||
list = evt.config["bridge.filter.list"]
|
|
||||||
|
|
||||||
if action in ("blacklist", "whitelist"):
|
|
||||||
action = "add" if mode == action else "remove"
|
|
||||||
|
|
||||||
def save():
|
|
||||||
evt.config["bridge.filter.list"] = list
|
|
||||||
evt.config.save()
|
|
||||||
po.Portal.filter_list = list
|
|
||||||
|
|
||||||
if action == "add":
|
|
||||||
if id in list:
|
|
||||||
return await evt.reply(f"That chat is already {mode}ed.")
|
|
||||||
list.append(id)
|
|
||||||
save()
|
|
||||||
return await evt.reply(f"Chat ID added to {mode}.")
|
|
||||||
elif action == "remove":
|
|
||||||
if id not in list:
|
|
||||||
return await evt.reply(f"That chat is not {mode}ed.")
|
|
||||||
list.remove(id)
|
|
||||||
save()
|
|
||||||
return await evt.reply(f"Chat ID removed from {mode}.")
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from telethon.errors import *
|
|
||||||
from telethon.tl.types import User as TLUser
|
|
||||||
from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
|
|
||||||
from telethon.tl.functions.channels import JoinChannelRequest
|
|
||||||
|
|
||||||
from .. import puppet as pu, portal as po
|
|
||||||
from . import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(help_section=SECTION_MISC,
|
|
||||||
help_args="[_-r|--remote_] <_query_>",
|
|
||||||
help_text="Search your contacts or the Telegram servers for users.")
|
|
||||||
async def search(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
|
|
||||||
|
|
||||||
force_remote = False
|
|
||||||
if evt.args[0] in {"-r", "--remote"}:
|
|
||||||
force_remote = True
|
|
||||||
evt.args.pop(0)
|
|
||||||
|
|
||||||
query = " ".join(evt.args)
|
|
||||||
if force_remote and len(query) < 5:
|
|
||||||
return await evt.reply("Minimum length of query for remote search is 5 characters.")
|
|
||||||
|
|
||||||
results, remote = await evt.sender.search(query, force_remote)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
if len(query) < 5 and remote:
|
|
||||||
return await evt.reply("No local results. "
|
|
||||||
"Minimum length of remote query is 5 characters.")
|
|
||||||
return await evt.reply("No results 3:")
|
|
||||||
|
|
||||||
reply = []
|
|
||||||
if remote:
|
|
||||||
reply += ["**Results from Telegram server:**", ""]
|
|
||||||
else:
|
|
||||||
reply += ["**Results in contacts:**", ""]
|
|
||||||
reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
|
|
||||||
f"{puppet.id} ({similarity}% match)")
|
|
||||||
for puppet, similarity in results]
|
|
||||||
|
|
||||||
# TODO somehow show remote channel results when joining by alias is possible?
|
|
||||||
|
|
||||||
return await evt.reply("\n".join(reply))
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(name="pm",
|
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="<_identifier_>",
|
|
||||||
help_text="Open a private chat with the given Telegram user. The identifier is "
|
|
||||||
"either the internal user ID, the username or the phone number. "
|
|
||||||
"**N.B.** The phone numbers you start chats with must already be in "
|
|
||||||
"your contacts.")
|
|
||||||
async def private_message(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = await evt.sender.client.get_entity(evt.args[0])
|
|
||||||
except ValueError:
|
|
||||||
return await evt.reply("Invalid user identifier or user not found.")
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
return await evt.reply("User not found.")
|
|
||||||
elif not isinstance(user, TLUser):
|
|
||||||
return await evt.reply("That doesn't seem to be a user.")
|
|
||||||
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
|
|
||||||
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
|
|
||||||
return await evt.reply("Created private chat room with "
|
|
||||||
f"{pu.Puppet.get_displayname(user, False)}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _join(evt: CommandEvent, arg: str):
|
|
||||||
if arg.startswith("joinchat/"):
|
|
||||||
invite_hash = arg[len("joinchat/"):]
|
|
||||||
try:
|
|
||||||
await evt.sender.client(CheckChatInviteRequest(invite_hash))
|
|
||||||
except InviteHashInvalidError:
|
|
||||||
return None, await evt.reply("Invalid invite link.")
|
|
||||||
except InviteHashExpiredError:
|
|
||||||
return None, await evt.reply("Invite link expired.")
|
|
||||||
try:
|
|
||||||
return (await evt.sender.client(ImportChatInviteRequest(invite_hash))), None
|
|
||||||
except UserAlreadyParticipantError:
|
|
||||||
return None, await evt.reply("You are already in that chat.")
|
|
||||||
else:
|
|
||||||
channel = await evt.sender.client.get_entity(arg)
|
|
||||||
if not channel:
|
|
||||||
return None, await evt.reply("Channel/supergroup not found.")
|
|
||||||
return await evt.sender.client(JoinChannelRequest(channel)), None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="<_link_>",
|
|
||||||
help_text="Join a chat with an invite link.")
|
|
||||||
async def join(evt: CommandEvent):
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
|
|
||||||
|
|
||||||
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
|
|
||||||
arg = regex.match(evt.args[0])
|
|
||||||
if not arg:
|
|
||||||
return await evt.reply("That doesn't look like a Telegram invite link.")
|
|
||||||
|
|
||||||
updates, _ = await _join(evt, arg.group(1))
|
|
||||||
if not updates:
|
|
||||||
return
|
|
||||||
|
|
||||||
for chat in updates.chats:
|
|
||||||
portal = po.Portal.get_by_entity(chat)
|
|
||||||
if portal.mxid:
|
|
||||||
await portal.invite_to_matrix([evt.sender.mxid])
|
|
||||||
return await evt.reply(f"Invited you to portal of {portal.title}")
|
|
||||||
else:
|
|
||||||
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
|
|
||||||
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
|
|
||||||
return await evt.reply(f"Created room for {portal.title}")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(help_section=SECTION_MISC,
|
|
||||||
help_args="[`chats`|`contacts`|`me`]",
|
|
||||||
help_text="Synchronize your chat portals, contacts and/or own info.")
|
|
||||||
async def sync(evt: CommandEvent):
|
|
||||||
if len(evt.args) > 0:
|
|
||||||
sync_only = evt.args[0]
|
|
||||||
if sync_only not in ("chats", "contacts", "me"):
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp sync [chats|contacts|me]`")
|
|
||||||
else:
|
|
||||||
sync_only = None
|
|
||||||
|
|
||||||
if not sync_only or sync_only == "chats":
|
|
||||||
await evt.sender.sync_dialogs(synchronous_create=True)
|
|
||||||
if not sync_only or sync_only == "contacts":
|
|
||||||
await evt.sender.sync_contacts()
|
|
||||||
if not sync_only or sync_only == "me":
|
|
||||||
await evt.sender.update_info()
|
|
||||||
return await evt.reply("Synchronization complete.")
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Tuple, Any, Optional
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
from ruamel.yaml.comments import CommentedMap
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
|
|
||||||
yaml = YAML()
|
|
||||||
yaml.indent(4)
|
|
||||||
|
|
||||||
|
|
||||||
class DictWithRecursion:
|
|
||||||
def __init__(self, data: CommentedMap = None):
|
|
||||||
self._data = data or CommentedMap() # type: CommentedMap
|
|
||||||
|
|
||||||
def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any:
|
|
||||||
if '.' in key:
|
|
||||||
key, next_key = key.split('.', 1)
|
|
||||||
next_data = data.get(key, CommentedMap())
|
|
||||||
return self._recursive_get(next_data, next_key, default_value)
|
|
||||||
return data.get(key, default_value)
|
|
||||||
|
|
||||||
def get(self, key: str, default_value: Any, allow_recursion: bool = True) -> Any:
|
|
||||||
if allow_recursion and '.' in key:
|
|
||||||
return self._recursive_get(self._data, key, default_value)
|
|
||||||
return self._data.get(key, default_value)
|
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> Any:
|
|
||||||
return self.get(key, None)
|
|
||||||
|
|
||||||
def __contains__(self, key: str) -> bool:
|
|
||||||
return self[key] is not None
|
|
||||||
|
|
||||||
def _recursive_set(self, data: CommentedMap, key: str, value: Any):
|
|
||||||
if '.' in key:
|
|
||||||
key, next_key = key.split('.', 1)
|
|
||||||
if key not in data:
|
|
||||||
data[key] = CommentedMap()
|
|
||||||
next_data = data.get(key, CommentedMap())
|
|
||||||
self._recursive_set(next_data, next_key, value)
|
|
||||||
return
|
|
||||||
data[key] = value
|
|
||||||
|
|
||||||
def set(self, key: str, value: Any, allow_recursion: bool = True):
|
|
||||||
if allow_recursion and '.' in key:
|
|
||||||
self._recursive_set(self._data, key, value)
|
|
||||||
return
|
|
||||||
self._data[key] = value
|
|
||||||
|
|
||||||
def __setitem__(self, key: str, value: Any):
|
|
||||||
self.set(key, value)
|
|
||||||
|
|
||||||
def _recursive_del(self, data: CommentedMap, key: str):
|
|
||||||
if '.' in key:
|
|
||||||
key, next_key = key.split('.', 1)
|
|
||||||
if key not in data:
|
|
||||||
return
|
|
||||||
next_data = data[key]
|
|
||||||
self._recursive_del(next_data, next_key)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
del data[key]
|
|
||||||
del data.ca.items[key]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def delete(self, key: str, allow_recursion: bool = True):
|
|
||||||
if allow_recursion and '.' in key:
|
|
||||||
self._recursive_del(self._data, key)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
del self._data[key]
|
|
||||||
del self._data.ca.items[key]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __delitem__(self, key: str):
|
|
||||||
self.delete(key)
|
|
||||||
|
|
||||||
|
|
||||||
class Config(DictWithRecursion):
|
|
||||||
def __init__(self, path: str, registration_path: str, base_path: str):
|
|
||||||
super().__init__()
|
|
||||||
self.path = path # type: str
|
|
||||||
self.registration_path = registration_path # type: str
|
|
||||||
self.base_path = base_path # type: str
|
|
||||||
self._registration = None # type: dict
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
with open(self.path, 'r') as stream:
|
|
||||||
self._data = yaml.load(stream)
|
|
||||||
|
|
||||||
def load_base(self) -> Optional[DictWithRecursion]:
|
|
||||||
try:
|
|
||||||
with open(self.base_path, 'r') as stream:
|
|
||||||
return DictWithRecursion(yaml.load(stream))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
with open(self.path, 'w') as stream:
|
|
||||||
yaml.dump(self._data, stream)
|
|
||||||
if self._registration and self.registration_path:
|
|
||||||
with open(self.registration_path, 'w') as stream:
|
|
||||||
yaml.dump(self._registration, stream)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _new_token() -> str:
|
|
||||||
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
base = self.load_base()
|
|
||||||
if not base:
|
|
||||||
return
|
|
||||||
|
|
||||||
def copy(from_path, to_path=None):
|
|
||||||
if from_path in self:
|
|
||||||
base[to_path or from_path] = self[from_path]
|
|
||||||
|
|
||||||
def copy_dict(from_path, to_path=None, override_existing_map=True):
|
|
||||||
if from_path in self:
|
|
||||||
to_path = to_path or from_path
|
|
||||||
if override_existing_map or to_path not in base:
|
|
||||||
base[to_path] = CommentedMap()
|
|
||||||
for key, value in self[from_path].items():
|
|
||||||
base[to_path][key] = value
|
|
||||||
|
|
||||||
copy("homeserver.address")
|
|
||||||
copy("homeserver.verify_ssl")
|
|
||||||
copy("homeserver.domain")
|
|
||||||
|
|
||||||
if "appservice.protocol" in self and "appservice.address" not in self:
|
|
||||||
protocol, hostname, port = (self["appservice.protocol"], self["appservice.hostname"],
|
|
||||||
self["appservice.port"])
|
|
||||||
base["appservice.address"] = f"{protocol}://{hostname}:{port}"
|
|
||||||
else:
|
|
||||||
copy("appservice.address")
|
|
||||||
copy("appservice.hostname")
|
|
||||||
copy("appservice.port")
|
|
||||||
copy("appservice.max_body_size")
|
|
||||||
|
|
||||||
copy("appservice.database")
|
|
||||||
|
|
||||||
copy("appservice.public.enabled")
|
|
||||||
copy("appservice.public.prefix")
|
|
||||||
copy("appservice.public.external")
|
|
||||||
|
|
||||||
copy("appservice.provisioning.enabled")
|
|
||||||
copy("appservice.provisioning.prefix")
|
|
||||||
copy("appservice.provisioning.shared_secret")
|
|
||||||
if base["appservice.provisioning.shared_secret"] == "generate":
|
|
||||||
base["appservice.provisioning.shared_secret"] = self._new_token()
|
|
||||||
|
|
||||||
copy("appservice.id")
|
|
||||||
copy("appservice.bot_username")
|
|
||||||
copy("appservice.bot_displayname")
|
|
||||||
copy("appservice.bot_avatar")
|
|
||||||
|
|
||||||
copy("appservice.as_token")
|
|
||||||
copy("appservice.hs_token")
|
|
||||||
|
|
||||||
copy("bridge.username_template")
|
|
||||||
copy("bridge.alias_template")
|
|
||||||
copy("bridge.displayname_template")
|
|
||||||
|
|
||||||
copy("bridge.displayname_preference")
|
|
||||||
|
|
||||||
copy("bridge.edits_as_replies")
|
|
||||||
copy("bridge.highlight_edits")
|
|
||||||
copy("bridge.bridge_notices")
|
|
||||||
copy("bridge.bot_messages_as_notices")
|
|
||||||
copy("bridge.max_initial_member_sync")
|
|
||||||
copy("bridge.sync_channel_members")
|
|
||||||
copy("bridge.max_telegram_delete")
|
|
||||||
copy("bridge.allow_matrix_login")
|
|
||||||
copy("bridge.inline_images")
|
|
||||||
copy("bridge.plaintext_highlights")
|
|
||||||
copy("bridge.public_portals")
|
|
||||||
copy("bridge.native_stickers")
|
|
||||||
copy("bridge.catch_up")
|
|
||||||
copy("bridge.sync_with_custom_puppets")
|
|
||||||
|
|
||||||
if "bridge.message_formats.m_text" in self:
|
|
||||||
del self["bridge.message_formats"]
|
|
||||||
copy_dict("bridge.message_formats", override_existing_map=False)
|
|
||||||
copy("bridge.state_event_formats.join")
|
|
||||||
copy("bridge.state_event_formats.leave")
|
|
||||||
copy("bridge.state_event_formats.name_change")
|
|
||||||
|
|
||||||
copy("bridge.filter.mode")
|
|
||||||
copy("bridge.filter.list")
|
|
||||||
|
|
||||||
copy("bridge.command_prefix")
|
|
||||||
|
|
||||||
migrate_permissions = ("bridge.permissions" not in self
|
|
||||||
or "bridge.whitelist" in self
|
|
||||||
or "bridge.admins" in self)
|
|
||||||
if migrate_permissions:
|
|
||||||
permissions = self["bridge.permissions"] or CommentedMap()
|
|
||||||
for entry in self["bridge.whitelist"] or []:
|
|
||||||
permissions[entry] = "full"
|
|
||||||
for entry in self["bridge.admins"] or []:
|
|
||||||
permissions[entry] = "admin"
|
|
||||||
base["bridge.permissions"] = permissions
|
|
||||||
else:
|
|
||||||
copy_dict("bridge.permissions")
|
|
||||||
|
|
||||||
if "bridge.relaybot" not in self:
|
|
||||||
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
|
|
||||||
else:
|
|
||||||
copy("bridge.relaybot.authless_portals")
|
|
||||||
copy("bridge.relaybot.whitelist_group_admins")
|
|
||||||
copy("bridge.relaybot.whitelist")
|
|
||||||
copy("bridge.relaybot.ignore_own_incoming_events")
|
|
||||||
|
|
||||||
copy("telegram.api_id")
|
|
||||||
copy("telegram.api_hash")
|
|
||||||
copy("telegram.bot_token")
|
|
||||||
copy("telegram.proxy.type")
|
|
||||||
copy("telegram.proxy.address")
|
|
||||||
copy("telegram.proxy.port")
|
|
||||||
copy("telegram.proxy.rdns")
|
|
||||||
copy("telegram.proxy.username")
|
|
||||||
copy("telegram.proxy.password")
|
|
||||||
|
|
||||||
if "appservice.debug" in self and "logging" not in self:
|
|
||||||
level = "DEBUG" if self["appservice.debug"] else "INFO"
|
|
||||||
base["logging.root.level"] = level
|
|
||||||
base["logging.loggers.mau.level"] = level
|
|
||||||
base["logging.loggers.telethon.level"] = level
|
|
||||||
else:
|
|
||||||
copy("logging")
|
|
||||||
|
|
||||||
self._data = base._data
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
|
|
||||||
level = self["bridge.permissions"].get(key, "")
|
|
||||||
admin = level == "admin"
|
|
||||||
matrix_puppeting = level == "full" or admin
|
|
||||||
puppeting = level == "puppeting" or matrix_puppeting
|
|
||||||
user = level == "user" or puppeting
|
|
||||||
relaybot = level == "relaybot" or user
|
|
||||||
return relaybot, user, puppeting, matrix_puppeting, admin, level
|
|
||||||
|
|
||||||
def get_permissions(self, mxid: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
|
|
||||||
permissions = self["bridge.permissions"] or {}
|
|
||||||
if mxid in permissions:
|
|
||||||
return self._get_permissions(mxid)
|
|
||||||
|
|
||||||
homeserver = mxid[mxid.index(":") + 1:]
|
|
||||||
if homeserver in permissions:
|
|
||||||
return self._get_permissions(homeserver)
|
|
||||||
|
|
||||||
return self._get_permissions("*")
|
|
||||||
|
|
||||||
def generate_registration(self):
|
|
||||||
homeserver = self["homeserver.domain"]
|
|
||||||
|
|
||||||
username_format = self.get("bridge.username_template", "telegram_{userid}") \
|
|
||||||
.format(userid=".+")
|
|
||||||
alias_format = self.get("bridge.alias_template", "telegram_{groupname}") \
|
|
||||||
.format(groupname=".+")
|
|
||||||
|
|
||||||
self.set("appservice.as_token", self._new_token())
|
|
||||||
self.set("appservice.hs_token", self._new_token())
|
|
||||||
|
|
||||||
self._registration = {
|
|
||||||
"id": self["appservice.id"] or "telegram",
|
|
||||||
"as_token": self["appservice.as_token"],
|
|
||||||
"hs_token": self["appservice.hs_token"],
|
|
||||||
"namespaces": {
|
|
||||||
"users": [{
|
|
||||||
"exclusive": True,
|
|
||||||
"regex": f"@{username_format}:{homeserver}"
|
|
||||||
}],
|
|
||||||
"aliases": [{
|
|
||||||
"exclusive": True,
|
|
||||||
"regex": f"#{alias_format}:{homeserver}"
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"url": self["appservice.address"],
|
|
||||||
"sender_localpart": self["appservice.bot_username"],
|
|
||||||
"rate_limited": False
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from sqlalchemy.orm import scoped_session
|
|
||||||
|
|
||||||
from alchemysession import AlchemySessionContainer
|
|
||||||
from mautrix_appservice import AppService
|
|
||||||
|
|
||||||
from .web import PublicBridgeWebsite, ProvisioningAPI
|
|
||||||
from .config import Config
|
|
||||||
from .bot import Bot
|
|
||||||
from .matrix import MatrixHandler
|
|
||||||
|
|
||||||
|
|
||||||
class Context:
|
|
||||||
def __init__(self, az: "AppService", db: "scoped_session", config: "Config",
|
|
||||||
loop: "asyncio.AbstractEventLoop", session_container: "AlchemySessionContainer"):
|
|
||||||
self.az = az # type: AppService
|
|
||||||
self.db = db # type: scoped_session
|
|
||||||
self.config = config # type: Config
|
|
||||||
self.loop = loop # type: asyncio.AbstractEventLoop
|
|
||||||
self.bot = None # type: Optional[Bot]
|
|
||||||
self.mx = None # type: MatrixHandler
|
|
||||||
self.session_container = session_container # type: AlchemySessionContainer
|
|
||||||
self.public_website = None # type: PublicBridgeWebsite
|
|
||||||
self.provisioning_api = None # type: ProvisioningAPI
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
yield self.az
|
|
||||||
yield self.db
|
|
||||||
yield self.config
|
|
||||||
yield self.loop
|
|
||||||
yield self.bot
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from sqlalchemy import (Column, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer,
|
|
||||||
BigInteger, String, Boolean, Text)
|
|
||||||
from sqlalchemy.sql import expression
|
|
||||||
from sqlalchemy.orm import relationship, Query
|
|
||||||
import json
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Portal(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "portal"
|
|
||||||
|
|
||||||
# Telegram chat information
|
|
||||||
tgid = Column(Integer, primary_key=True)
|
|
||||||
tg_receiver = Column(Integer, primary_key=True)
|
|
||||||
peer_type = Column(String, nullable=False)
|
|
||||||
megagroup = Column(Boolean)
|
|
||||||
|
|
||||||
# Matrix portal information
|
|
||||||
mxid = Column(String, unique=True, nullable=True)
|
|
||||||
|
|
||||||
# Telegram chat metadata
|
|
||||||
username = Column(String, nullable=True)
|
|
||||||
title = Column(String, nullable=True)
|
|
||||||
about = Column(String, nullable=True)
|
|
||||||
photo_id = Column(String, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Message(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "message"
|
|
||||||
|
|
||||||
mxid = Column(String)
|
|
||||||
mx_room = Column(String)
|
|
||||||
tgid = Column(Integer, primary_key=True)
|
|
||||||
tg_space = Column(Integer, primary_key=True)
|
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
|
|
||||||
|
|
||||||
|
|
||||||
class UserPortal(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "user_portal"
|
|
||||||
|
|
||||||
user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"),
|
|
||||||
primary_key=True)
|
|
||||||
portal = Column(Integer, primary_key=True)
|
|
||||||
portal_receiver = Column(Integer, primary_key=True)
|
|
||||||
|
|
||||||
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
|
|
||||||
("portal.tgid", "portal.tg_receiver"),
|
|
||||||
onupdate="CASCADE", ondelete="CASCADE"),)
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "user"
|
|
||||||
|
|
||||||
mxid = Column(String, primary_key=True)
|
|
||||||
tgid = Column(Integer, nullable=True, unique=True)
|
|
||||||
tg_username = Column(String, nullable=True)
|
|
||||||
saved_contacts = Column(Integer, default=0, nullable=False)
|
|
||||||
contacts = relationship("Contact", uselist=True,
|
|
||||||
cascade="save-update, merge, delete, delete-orphan")
|
|
||||||
portals = relationship("Portal", secondary="user_portal")
|
|
||||||
|
|
||||||
|
|
||||||
class RoomState(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "mx_room_state"
|
|
||||||
|
|
||||||
room_id = Column(String, primary_key=True)
|
|
||||||
_power_levels_text = Column("power_levels", Text, nullable=True)
|
|
||||||
_power_levels_json = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_power_levels(self):
|
|
||||||
return bool(self._power_levels_text)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def power_levels(self):
|
|
||||||
if not self._power_levels_json and self._power_levels_text:
|
|
||||||
self._power_levels_json = json.loads(self._power_levels_text)
|
|
||||||
return self._power_levels_json or {}
|
|
||||||
|
|
||||||
@power_levels.setter
|
|
||||||
def power_levels(self, val):
|
|
||||||
self._power_levels_json = val
|
|
||||||
self._power_levels_text = json.dumps(val)
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "mx_user_profile"
|
|
||||||
|
|
||||||
room_id = Column(String, primary_key=True)
|
|
||||||
user_id = Column(String, primary_key=True)
|
|
||||||
membership = Column(String, nullable=False, default="leave")
|
|
||||||
displayname = Column(String, nullable=True)
|
|
||||||
avatar_url = Column(String, nullable=True)
|
|
||||||
|
|
||||||
def dict(self):
|
|
||||||
return {
|
|
||||||
"membership": self.membership,
|
|
||||||
"displayname": self.displayname,
|
|
||||||
"avatar_url": self.avatar_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Contact(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "contact"
|
|
||||||
|
|
||||||
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
|
|
||||||
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Puppet(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "puppet"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
|
||||||
custom_mxid = Column(String, nullable=True)
|
|
||||||
access_token = Column(String, nullable=True)
|
|
||||||
displayname = Column(String, nullable=True)
|
|
||||||
displayname_source = Column(Integer, nullable=True)
|
|
||||||
username = Column(String, nullable=True)
|
|
||||||
photo_id = Column(String, nullable=True)
|
|
||||||
is_bot = Column(Boolean, nullable=True)
|
|
||||||
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false())
|
|
||||||
|
|
||||||
|
|
||||||
# Fucking Telegram not telling bots what chats they are in 3:<
|
|
||||||
class BotChat(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "bot_chat"
|
|
||||||
id = Column(Integer, primary_key=True)
|
|
||||||
type = Column(String, nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramFile(Base):
|
|
||||||
query = None # type: Query
|
|
||||||
__tablename__ = "telegram_file"
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True)
|
|
||||||
mxc = Column(String)
|
|
||||||
mime_type = Column(String)
|
|
||||||
was_converted = Column(Boolean)
|
|
||||||
timestamp = Column(BigInteger)
|
|
||||||
size = Column(Integer, nullable=True)
|
|
||||||
width = Column(Integer, nullable=True)
|
|
||||||
height = Column(Integer, nullable=True)
|
|
||||||
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
|
|
||||||
thumbnail = relationship("TelegramFile", uselist=False)
|
|
||||||
|
|
||||||
|
|
||||||
def init(db_session):
|
|
||||||
Portal.query = db_session.query_property()
|
|
||||||
Message.query = db_session.query_property()
|
|
||||||
UserPortal.query = db_session.query_property()
|
|
||||||
User.query = db_session.query_property()
|
|
||||||
Puppet.query = db_session.query_property()
|
|
||||||
BotChat.query = db_session.query_property()
|
|
||||||
TelegramFile.query = db_session.query_property()
|
|
||||||
UserProfile.query = db_session.query_property()
|
|
||||||
RoomState.query = db_session.query_property()
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
|
|
||||||
init_mx)
|
|
||||||
from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg)
|
|
||||||
from .. import context as c
|
|
||||||
|
|
||||||
|
|
||||||
def init(context: c.Context):
|
|
||||||
init_mx(context)
|
|
||||||
init_tg(context)
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING
|
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
|
|
||||||
TypeMessageEntity)
|
|
||||||
|
|
||||||
from ... import puppet as pu
|
|
||||||
from ...db import Message as DBMessage
|
|
||||||
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
|
|
||||||
trim_reply_fallback_text)
|
|
||||||
from .parser_common import ParsedMessage
|
|
||||||
|
|
||||||
try:
|
|
||||||
from mautrix_telegram.formatter.from_matrix.parser_lxml import parse_html
|
|
||||||
except ImportError:
|
|
||||||
from mautrix_telegram.formatter.from_matrix.parser_htmlparser import parse_html
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ...context import Context
|
|
||||||
|
|
||||||
log = logging.getLogger("mau.fmt.mx") # type: logging.Logger
|
|
||||||
should_bridge_plaintext_highlights = False # type: bool
|
|
||||||
|
|
||||||
command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern
|
|
||||||
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern
|
|
||||||
plain_mention_regex = None # type: Pattern
|
|
||||||
|
|
||||||
|
|
||||||
def plain_mention_to_html(match: Match) -> str:
|
|
||||||
puppet = pu.Puppet.find_by_displayname(match.group(2))
|
|
||||||
if puppet:
|
|
||||||
return (f"{match.group(1)}"
|
|
||||||
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
|
|
||||||
f"{puppet.displayname}"
|
|
||||||
"</a>")
|
|
||||||
return "".join(match.groups())
|
|
||||||
|
|
||||||
|
|
||||||
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
|
|
||||||
if len(message) > 4096:
|
|
||||||
message = message[0:4082] + " [message cut]"
|
|
||||||
new_entities = []
|
|
||||||
for entity in entities:
|
|
||||||
if entity.offset > 4082:
|
|
||||||
continue
|
|
||||||
if entity.offset + entity.length > 4082:
|
|
||||||
entity.length = 4082 - entity.offset
|
|
||||||
new_entities.append(entity)
|
|
||||||
new_entities.append(MessageEntityItalic(4082, len(" [message cut]")))
|
|
||||||
entities = new_entities
|
|
||||||
return message, entities
|
|
||||||
|
|
||||||
|
|
||||||
class FormatError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def matrix_to_telegram(html: str) -> ParsedMessage:
|
|
||||||
try:
|
|
||||||
html = command_regex.sub(r"<command>\1</command>", html)
|
|
||||||
html = html.replace("\t", " " * 4)
|
|
||||||
html = not_command_regex.sub(r"\1", html)
|
|
||||||
if should_bridge_plaintext_highlights:
|
|
||||||
html = plain_mention_regex.sub(plain_mention_to_html, html)
|
|
||||||
|
|
||||||
html = add_surrogates(html)
|
|
||||||
text, entities = parse_html(add_surrogates(html))
|
|
||||||
text = remove_surrogates(text.strip())
|
|
||||||
text, entities = cut_long_message(text, entities)
|
|
||||||
|
|
||||||
return text, entities
|
|
||||||
except Exception as e:
|
|
||||||
raise FormatError(f"Failed to convert Matrix format: {html}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def matrix_reply_to_telegram(content: dict, tg_space: int, room_id: Optional[str] = None
|
|
||||||
) -> Optional[int]:
|
|
||||||
try:
|
|
||||||
reply = content["m.relates_to"]["m.in_reply_to"]
|
|
||||||
room_id = room_id or reply["room_id"]
|
|
||||||
event_id = reply["event_id"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
if content["format"] == "org.matrix.custom.html":
|
|
||||||
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
content["body"] = trim_reply_fallback_text(content["body"])
|
|
||||||
|
|
||||||
message = DBMessage.query.filter(DBMessage.mxid == event_id,
|
|
||||||
DBMessage.tg_space == tg_space,
|
|
||||||
DBMessage.mx_room == room_id).one_or_none()
|
|
||||||
if message:
|
|
||||||
return message.tgid
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def matrix_text_to_telegram(text: str) -> ParsedMessage:
|
|
||||||
text = command_regex.sub(r"/\1", text)
|
|
||||||
text = text.replace("\t", " " * 4)
|
|
||||||
text = not_command_regex.sub(r"\1", text)
|
|
||||||
if should_bridge_plaintext_highlights:
|
|
||||||
entities, pmr_replacer = plain_mention_to_text()
|
|
||||||
text = plain_mention_regex.sub(pmr_replacer, text)
|
|
||||||
else:
|
|
||||||
entities = []
|
|
||||||
return text, entities
|
|
||||||
|
|
||||||
|
|
||||||
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
|
|
||||||
entities = []
|
|
||||||
|
|
||||||
def replacer(match) -> str:
|
|
||||||
puppet = pu.Puppet.find_by_displayname(match.group(2))
|
|
||||||
if puppet:
|
|
||||||
offset = match.start()
|
|
||||||
length = match.end() - offset
|
|
||||||
if puppet.username:
|
|
||||||
entity = MessageEntityMention(offset, length)
|
|
||||||
text = f"@{puppet.username}"
|
|
||||||
else:
|
|
||||||
entity = MessageEntityMentionName(offset, length, user_id=puppet.tgid)
|
|
||||||
text = puppet.displayname
|
|
||||||
entities.append(entity)
|
|
||||||
return text
|
|
||||||
return "".join(match.groups())
|
|
||||||
|
|
||||||
return entities, replacer
|
|
||||||
|
|
||||||
|
|
||||||
def init_mx(context: "Context"):
|
|
||||||
global plain_mention_regex, should_bridge_plaintext_highlights
|
|
||||||
config = context.config
|
|
||||||
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
|
|
||||||
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
|
|
||||||
plain_mention_regex = re.compile(f"(\s|^)({dn_template})")
|
|
||||||
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
import re
|
|
||||||
from typing import List, Tuple, Pattern
|
|
||||||
from telethon.tl.types import TypeMessageEntity
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixParserCommon:
|
|
||||||
mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern
|
|
||||||
room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern
|
|
||||||
block_tags = ("br", "p", "pre", "blockquote",
|
|
||||||
"ol", "ul", "li",
|
|
||||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
||||||
"div", "hr", "table") # type: Tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import (Optional, List, Tuple, Type, Dict, Any, Deque, Match)
|
|
||||||
from html import unescape
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
from collections import deque
|
|
||||||
import math
|
|
||||||
|
|
||||||
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityEmail,
|
|
||||||
MessageEntityUrl, MessageEntityTextUrl, MessageEntityBold,
|
|
||||||
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
|
|
||||||
MessageEntityBotCommand, TypeMessageEntity)
|
|
||||||
|
|
||||||
from ... import user as u, puppet as pu, portal as po
|
|
||||||
from ..util import html_to_unicode
|
|
||||||
from .parser_common import MatrixParserCommon, ParsedMessage
|
|
||||||
|
|
||||||
|
|
||||||
def parse_html(html: str) -> ParsedMessage:
|
|
||||||
parser = MatrixParser()
|
|
||||||
parser.feed(html)
|
|
||||||
return parser.text, parser.entities
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixParser(HTMLParser, MatrixParserCommon):
|
|
||||||
def __init__(self):
|
|
||||||
super(MatrixParser, self).__init__()
|
|
||||||
self.text = "" # type: str
|
|
||||||
self.entities = [] # type: List[TypeMessageEntity]
|
|
||||||
self._building_entities = {} # type: Dict[str, TypeMessageEntity]
|
|
||||||
self._list_counter = 0 # type: int
|
|
||||||
self._open_tags = deque() # type: Deque[str]
|
|
||||||
self._open_tags_meta = deque() # type: Deque[Any]
|
|
||||||
self._line_is_new = True # type: bool
|
|
||||||
self._list_entry_is_new = False # type: bool
|
|
||||||
|
|
||||||
def _parse_url(self, url: str, args: Dict[str, Any]
|
|
||||||
) -> Tuple[Optional[Type[TypeMessageEntity]], Optional[str]]:
|
|
||||||
mention = self.mention_regex.match(url) # type: Match
|
|
||||||
if mention:
|
|
||||||
mxid = mention.group(1)
|
|
||||||
user = (pu.Puppet.get_by_mxid(mxid)
|
|
||||||
or u.User.get_by_mxid(mxid, create=False))
|
|
||||||
if not user:
|
|
||||||
return None, None
|
|
||||||
if user.username:
|
|
||||||
return MessageEntityMention, f"@{user.username}"
|
|
||||||
elif user.tgid:
|
|
||||||
args["user_id"] = user.tgid
|
|
||||||
return MessageEntityMentionName, user.displayname or None
|
|
||||||
else:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
room = self.room_regex.match(url) # type: Match
|
|
||||||
if room:
|
|
||||||
username = po.Portal.get_username_from_mx_alias(room.group(1))
|
|
||||||
portal = po.Portal.find_by_username(username)
|
|
||||||
if portal and portal.username:
|
|
||||||
return MessageEntityMention, f"@{portal.username}"
|
|
||||||
|
|
||||||
if url.startswith("mailto:"):
|
|
||||||
return MessageEntityEmail, url[len("mailto:"):]
|
|
||||||
elif self.get_starttag_text() == url:
|
|
||||||
return MessageEntityUrl, url
|
|
||||||
else:
|
|
||||||
args["url"] = url
|
|
||||||
return MessageEntityTextUrl, None
|
|
||||||
|
|
||||||
def handle_starttag(self, tag: str, attrs: List[Tuple[str, str]]):
|
|
||||||
self._open_tags.appendleft(tag)
|
|
||||||
self._open_tags_meta.appendleft(0)
|
|
||||||
|
|
||||||
attrs = dict(attrs)
|
|
||||||
entity_type = None # type: type(TypeMessageEntity)
|
|
||||||
args = {} # type: Dict[str, Any]
|
|
||||||
if tag in ("strong", "b"):
|
|
||||||
entity_type = MessageEntityBold
|
|
||||||
elif tag in ("em", "i"):
|
|
||||||
entity_type = MessageEntityItalic
|
|
||||||
elif tag == "code":
|
|
||||||
try:
|
|
||||||
pre = self._building_entities["pre"]
|
|
||||||
try:
|
|
||||||
# Pre tag and language found, add language to MessageEntityPre
|
|
||||||
pre.language = attrs["class"][len("language-"):]
|
|
||||||
except KeyError:
|
|
||||||
# Pre tag found, but language not found, keep pre as-is
|
|
||||||
pass
|
|
||||||
except KeyError:
|
|
||||||
# No pre tag found, this is inline code
|
|
||||||
entity_type = MessageEntityCode
|
|
||||||
elif tag == "pre":
|
|
||||||
entity_type = MessageEntityPre
|
|
||||||
args["language"] = ""
|
|
||||||
elif tag == "command":
|
|
||||||
entity_type = MessageEntityBotCommand
|
|
||||||
elif tag == "li":
|
|
||||||
self._list_entry_is_new = True
|
|
||||||
elif tag == "a":
|
|
||||||
try:
|
|
||||||
url = attrs["href"]
|
|
||||||
except KeyError:
|
|
||||||
return
|
|
||||||
entity_type, url = self._parse_url(url, args)
|
|
||||||
self._open_tags_meta.popleft()
|
|
||||||
self._open_tags_meta.appendleft(url)
|
|
||||||
|
|
||||||
if tag in self.block_tags and ("blockquote" not in self._open_tags or tag == "br"):
|
|
||||||
self._newline()
|
|
||||||
|
|
||||||
if entity_type and tag not in self._building_entities:
|
|
||||||
offset = len(self.text)
|
|
||||||
self._building_entities[tag] = entity_type(offset=offset, length=0, **args)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _list_indent(self) -> int:
|
|
||||||
indent = 0
|
|
||||||
first_skipped = False
|
|
||||||
for index, tag in enumerate(self._open_tags):
|
|
||||||
if not first_skipped and tag in ("ol", "ul"):
|
|
||||||
# The first list level isn't indented, so skip it.
|
|
||||||
first_skipped = True
|
|
||||||
continue
|
|
||||||
if tag == "ol":
|
|
||||||
n = self._open_tags_meta[index]
|
|
||||||
extra_length_for_long_index = (int(math.log(n, 10)) - 1) * 3
|
|
||||||
indent += 4 + extra_length_for_long_index
|
|
||||||
elif tag == "ul":
|
|
||||||
indent += 3
|
|
||||||
return indent
|
|
||||||
|
|
||||||
def _newline(self, allow_multi: bool = False):
|
|
||||||
if self._line_is_new and not allow_multi:
|
|
||||||
return
|
|
||||||
self.text += "\n"
|
|
||||||
self._line_is_new = True
|
|
||||||
for entity in self._building_entities.values():
|
|
||||||
entity.length += 1
|
|
||||||
|
|
||||||
def _handle_special_previous_tags(self, text: str) -> str:
|
|
||||||
if "pre" not in self._open_tags and "code" not in self._open_tags:
|
|
||||||
text = text.replace("\n", "")
|
|
||||||
else:
|
|
||||||
text = text.strip()
|
|
||||||
|
|
||||||
previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ""
|
|
||||||
if previous_tag == "a":
|
|
||||||
url = self._open_tags_meta[0]
|
|
||||||
if url:
|
|
||||||
text = url
|
|
||||||
elif previous_tag == "command":
|
|
||||||
text = f"/{text}"
|
|
||||||
return text
|
|
||||||
|
|
||||||
def _html_to_unicode(self, text: str) -> str:
|
|
||||||
strikethrough, underline = "del" in self._open_tags, "u" in self._open_tags
|
|
||||||
if strikethrough and underline:
|
|
||||||
text = html_to_unicode(text, "\u0336\u0332")
|
|
||||||
elif strikethrough:
|
|
||||||
text = html_to_unicode(text, "\u0336")
|
|
||||||
elif underline:
|
|
||||||
text = html_to_unicode(text, "\u0332")
|
|
||||||
return text
|
|
||||||
|
|
||||||
def _handle_tags_for_data(self, text: str) -> Tuple[str, int]:
|
|
||||||
extra_offset = 0
|
|
||||||
list_entry_handled_once = False
|
|
||||||
# In order to maintain order of things like blockquotes in lists or lists in blockquotes,
|
|
||||||
# we can't just have ifs/elses and we need to actually loop through the open tags in order.
|
|
||||||
for index, tag in enumerate(self._open_tags):
|
|
||||||
if tag == "blockquote" and self._line_is_new:
|
|
||||||
text = f"> {text}"
|
|
||||||
extra_offset += 2
|
|
||||||
elif tag == "li" and not list_entry_handled_once:
|
|
||||||
list_type_index = index + 1
|
|
||||||
list_type = self._open_tags[list_type_index]
|
|
||||||
indent = self._list_indent * " " if self._line_is_new else ""
|
|
||||||
if list_type == "ol":
|
|
||||||
n = self._open_tags_meta[list_type_index]
|
|
||||||
if self._list_entry_is_new:
|
|
||||||
n += 1
|
|
||||||
self._open_tags_meta[list_type_index] = n
|
|
||||||
prefix = f"{n}. "
|
|
||||||
else:
|
|
||||||
prefix = int(math.log(n, 10)) * 3 * " " + 4 * " "
|
|
||||||
else:
|
|
||||||
prefix = "* " if self._list_entry_is_new else 3 * " "
|
|
||||||
if not self._list_entry_is_new and not self._line_is_new:
|
|
||||||
prefix = ""
|
|
||||||
extra_offset += len(indent) + len(prefix)
|
|
||||||
text = indent + prefix + text
|
|
||||||
self._list_entry_is_new = False
|
|
||||||
list_entry_handled_once = True
|
|
||||||
return text, extra_offset
|
|
||||||
|
|
||||||
def _extend_entities_in_construction(self, text: str, extra_offset: int):
|
|
||||||
for tag, entity in self._building_entities.items():
|
|
||||||
entity.length += len(text) - extra_offset
|
|
||||||
entity.offset += extra_offset
|
|
||||||
|
|
||||||
def handle_data(self, text: str):
|
|
||||||
text = unescape(text)
|
|
||||||
text = self._handle_special_previous_tags(text)
|
|
||||||
text = self._html_to_unicode(text)
|
|
||||||
text, extra_offset = self._handle_tags_for_data(text)
|
|
||||||
self._extend_entities_in_construction(text, extra_offset)
|
|
||||||
self._line_is_new = False
|
|
||||||
self.text += text
|
|
||||||
|
|
||||||
def handle_endtag(self, tag: str):
|
|
||||||
try:
|
|
||||||
self._open_tags.popleft()
|
|
||||||
self._open_tags_meta.popleft()
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
entity = self._building_entities.pop(tag, None)
|
|
||||||
if entity:
|
|
||||||
self.entities.append(entity)
|
|
||||||
|
|
||||||
if tag in self.block_tags and tag != "br" and "blockquote" not in self._open_tags:
|
|
||||||
self._newline(allow_multi=tag == "br")
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, List, Tuple, Union, Callable
|
|
||||||
from lxml import html
|
|
||||||
|
|
||||||
from telethon.tl.types import (MessageEntityMention as Mention,
|
|
||||||
MessageEntityMentionName as MentionName, MessageEntityEmail as Email,
|
|
||||||
MessageEntityUrl as URL, MessageEntityTextUrl as TextURL,
|
|
||||||
MessageEntityBold as Bold, MessageEntityItalic as Italic,
|
|
||||||
MessageEntityCode as Code, MessageEntityPre as Pre,
|
|
||||||
MessageEntityBotCommand as Command, TypeMessageEntity,
|
|
||||||
InputMessageEntityMentionName as InputMentionName)
|
|
||||||
|
|
||||||
from ... import user as u, puppet as pu, portal as po
|
|
||||||
from ..util import html_to_unicode
|
|
||||||
from .parser_common import MatrixParserCommon, ParsedMessage
|
|
||||||
|
|
||||||
|
|
||||||
def parse_html(html: str) -> ParsedMessage:
|
|
||||||
return MatrixParser.parse(html)
|
|
||||||
|
|
||||||
|
|
||||||
class Entity:
|
|
||||||
@staticmethod
|
|
||||||
def copy(entity: TypeMessageEntity) -> Optional[TypeMessageEntity]:
|
|
||||||
if not entity:
|
|
||||||
return None
|
|
||||||
kwargs = {
|
|
||||||
"offset": entity.offset,
|
|
||||||
"length": entity.length,
|
|
||||||
}
|
|
||||||
if isinstance(entity, Pre):
|
|
||||||
kwargs["language"] = entity.language
|
|
||||||
elif isinstance(entity, TextURL):
|
|
||||||
kwargs["url"] = entity.url
|
|
||||||
elif isinstance(entity, (MentionName, InputMentionName)):
|
|
||||||
kwargs["user_id"] = entity.user_id
|
|
||||||
return entity.__class__(**kwargs)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def adjust(cls, entity: Union[TypeMessageEntity, List[TypeMessageEntity]],
|
|
||||||
func: Callable[[TypeMessageEntity], None]
|
|
||||||
) -> Union[Optional[TypeMessageEntity], List[TypeMessageEntity]]:
|
|
||||||
if isinstance(entity, list):
|
|
||||||
return [Entity.adjust(element, func) for element in entity if entity]
|
|
||||||
elif not entity:
|
|
||||||
return None
|
|
||||||
entity = cls.copy(entity)
|
|
||||||
func(entity)
|
|
||||||
if entity.offset < 0:
|
|
||||||
entity.length += entity.offset
|
|
||||||
entity.offset = 0
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
def offset_diff(amount: int):
|
|
||||||
def func(entity: TypeMessageEntity):
|
|
||||||
entity.offset += amount
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
|
|
||||||
def offset_length_multiply(amount: int):
|
|
||||||
def func(entity: TypeMessageEntity):
|
|
||||||
entity.offset *= amount
|
|
||||||
entity.length *= amount
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramMessage:
|
|
||||||
def __init__(self, text: str = "", entities: Optional[List[TypeMessageEntity]] = None):
|
|
||||||
self.text = text # type: str
|
|
||||||
self.entities = entities or [] # type: List[TypeMessageEntity]
|
|
||||||
|
|
||||||
def offset_entities(self, offset: int) -> "TelegramMessage":
|
|
||||||
def apply_offset(entity: TypeMessageEntity, inner_offset: int
|
|
||||||
) -> Optional[TypeMessageEntity]:
|
|
||||||
entity = Entity.copy(entity)
|
|
||||||
entity.offset += inner_offset
|
|
||||||
if entity.offset < 0:
|
|
||||||
entity.offset = 0
|
|
||||||
elif entity.offset > len(self.text):
|
|
||||||
return None
|
|
||||||
elif entity.offset + entity.length > len(self.text):
|
|
||||||
entity.length = len(self.text) - entity.offset
|
|
||||||
return entity
|
|
||||||
|
|
||||||
self.entities = [apply_offset(entity, offset) for entity in self.entities if entity]
|
|
||||||
self.entities = [x for x in self.entities if x is not None]
|
|
||||||
return self
|
|
||||||
|
|
||||||
def append(self, *args: Union[str, "TelegramMessage"]) -> "TelegramMessage":
|
|
||||||
for msg in args:
|
|
||||||
if isinstance(msg, str):
|
|
||||||
msg = TelegramMessage(text=msg)
|
|
||||||
self.entities += Entity.adjust(msg.entities, offset_diff(len(self.text)))
|
|
||||||
self.text += msg.text
|
|
||||||
return self
|
|
||||||
|
|
||||||
def prepend(self, *args: Union[str, "TelegramMessage"]) -> "TelegramMessage":
|
|
||||||
for msg in args:
|
|
||||||
if isinstance(msg, str):
|
|
||||||
msg = TelegramMessage(text=msg)
|
|
||||||
self.entities = msg.entities + Entity.adjust(self.entities, offset_diff(len(msg.text)))
|
|
||||||
self.text = msg.text + self.text
|
|
||||||
return self
|
|
||||||
|
|
||||||
def format(self, entity_type: type(TypeMessageEntity), offset: int = None, length: int = None,
|
|
||||||
**kwargs) -> "TelegramMessage":
|
|
||||||
self.entities.append(entity_type(offset=offset or 0,
|
|
||||||
length=length if length is not None else len(self.text),
|
|
||||||
**kwargs))
|
|
||||||
return self
|
|
||||||
|
|
||||||
def concat(self, *args: Union[str, "TelegramMessage"]) -> "TelegramMessage":
|
|
||||||
return TelegramMessage().append(self, *args)
|
|
||||||
|
|
||||||
def trim(self) -> "TelegramMessage":
|
|
||||||
orig_len = len(self.text)
|
|
||||||
self.text = self.text.lstrip()
|
|
||||||
diff = orig_len - len(self.text)
|
|
||||||
self.text = self.text.rstrip()
|
|
||||||
self.offset_entities(-diff)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def split(self, separator, max_items: int = 0) -> List["TelegramMessage"]:
|
|
||||||
text_parts = self.text.split(separator, max_items - 1)
|
|
||||||
output = [] # type: List[TelegramMessage]
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
for part in text_parts:
|
|
||||||
msg = TelegramMessage(part)
|
|
||||||
for entity in self.entities:
|
|
||||||
start_in_range = len(part) > entity.offset - offset >= 0
|
|
||||||
end_in_range = len(part) >= entity.offset - offset + entity.length > 0
|
|
||||||
if start_in_range and end_in_range:
|
|
||||||
msg.entities.append(Entity.adjust(entity, offset_diff(-offset)))
|
|
||||||
output.append(msg)
|
|
||||||
|
|
||||||
offset += len(part)
|
|
||||||
offset += len(separator)
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def join(items: List[Union[str, "TelegramMessage"]], separator: str = " ") -> "TelegramMessage":
|
|
||||||
main = TelegramMessage()
|
|
||||||
for msg in items:
|
|
||||||
if isinstance(msg, str):
|
|
||||||
msg = TelegramMessage(text=msg)
|
|
||||||
main.entities += Entity.adjust(msg.entities, offset_diff(len(main.text)))
|
|
||||||
main.text += msg.text + separator
|
|
||||||
main.text = main.text[:-len(separator)]
|
|
||||||
return main
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixParser(MatrixParserCommon):
|
|
||||||
@classmethod
|
|
||||||
def list_to_tmessage(cls, node: html.HtmlElement, strip_linebreaks) -> TelegramMessage:
|
|
||||||
ordered = node.tag == "ol"
|
|
||||||
tagged_children = cls.node_to_tagged_tmessages(node, strip_linebreaks)
|
|
||||||
counter = 1
|
|
||||||
indent_length = 0
|
|
||||||
if ordered:
|
|
||||||
try:
|
|
||||||
counter = int(node.attrib.get("start", "1"))
|
|
||||||
except ValueError:
|
|
||||||
counter = 1
|
|
||||||
|
|
||||||
longest_index = counter - 1 + len(tagged_children)
|
|
||||||
indent_length = len(str(longest_index))
|
|
||||||
indent = (indent_length + 4) * " "
|
|
||||||
children = [] # type: List[TelegramMessage]
|
|
||||||
for child, tag in tagged_children:
|
|
||||||
if tag != "li":
|
|
||||||
continue
|
|
||||||
|
|
||||||
if ordered:
|
|
||||||
prefix = f"{counter}. "
|
|
||||||
counter += 1
|
|
||||||
else:
|
|
||||||
prefix = "● "
|
|
||||||
child = child.prepend(prefix)
|
|
||||||
parts = child.split("\n")
|
|
||||||
parts = parts[:1] + [part.prepend(indent) for part in parts[1:]]
|
|
||||||
child = TelegramMessage.join(parts, "\n")
|
|
||||||
children.append(child)
|
|
||||||
return TelegramMessage.join(children, "\n")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def blockquote_to_tmessage(cls, node: html.HtmlElement, strip_linebreaks) -> TelegramMessage:
|
|
||||||
msg = cls.tag_aware_parse_node(node, strip_linebreaks)
|
|
||||||
children = msg.trim().split("\n")
|
|
||||||
children = [child.prepend("> ") for child in children]
|
|
||||||
return TelegramMessage.join(children, "\n")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def header_to_tmessage(cls, node: html.HtmlElement, strip_linebreaks) -> TelegramMessage:
|
|
||||||
children = cls.node_to_tmessages(node, strip_linebreaks)
|
|
||||||
length = int(node.tag[1])
|
|
||||||
prefix = "#" * length + " "
|
|
||||||
return TelegramMessage.join(children, "").prepend(prefix)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def basic_format_to_tmessage(cls, node: html.HtmlElement, strip_linebreaks) -> TelegramMessage:
|
|
||||||
msg = cls.tag_aware_parse_node(node, strip_linebreaks)
|
|
||||||
if node.tag in ("b", "strong"):
|
|
||||||
msg.format(Bold)
|
|
||||||
elif node.tag in ("i", "em"):
|
|
||||||
msg.format(Italic)
|
|
||||||
elif node.tag == "command":
|
|
||||||
msg.format(Command)
|
|
||||||
elif node.tag in ("s", "del"):
|
|
||||||
msg.text = html_to_unicode(msg.text, "\u0336")
|
|
||||||
elif node.tag in ("u", "ins"):
|
|
||||||
msg.text = html_to_unicode(msg.text, "\u0332")
|
|
||||||
|
|
||||||
if node.tag in ("s", "del", "u", "ins"):
|
|
||||||
msg.entities = Entity.adjust(msg.entities, offset_length_multiply(2))
|
|
||||||
|
|
||||||
return msg
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def link_to_tstring(cls, node: html.HtmlElement, strip_linebreaks) -> TelegramMessage:
|
|
||||||
msg = cls.tag_aware_parse_node(node, strip_linebreaks)
|
|
||||||
href = node.attrib.get("href", "")
|
|
||||||
if not href:
|
|
||||||
return msg
|
|
||||||
|
|
||||||
if href.startswith("mailto:"):
|
|
||||||
return TelegramMessage(href[len("mailto:"):]).format(Email)
|
|
||||||
|
|
||||||
mention = cls.mention_regex.match(href)
|
|
||||||
if mention:
|
|
||||||
mxid = mention.group(1)
|
|
||||||
user = (pu.Puppet.get_by_mxid(mxid)
|
|
||||||
or u.User.get_by_mxid(mxid, create=False))
|
|
||||||
if not user:
|
|
||||||
return msg
|
|
||||||
if user.username:
|
|
||||||
return TelegramMessage(f"@{user.username}").format(Mention)
|
|
||||||
elif user.tgid:
|
|
||||||
return TelegramMessage(user.displayname or msg.text).format(MentionName,
|
|
||||||
user_id=user.tgid)
|
|
||||||
return msg
|
|
||||||
|
|
||||||
room = cls.room_regex.match(href)
|
|
||||||
if room:
|
|
||||||
username = po.Portal.get_username_from_mx_alias(room.group(1))
|
|
||||||
portal = po.Portal.find_by_username(username)
|
|
||||||
if portal and portal.username:
|
|
||||||
return TelegramMessage(f"@{portal.username}").format(Mention)
|
|
||||||
|
|
||||||
return (msg.format(URL)
|
|
||||||
if msg.text == href
|
|
||||||
else msg.format(TextURL, url=href))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def node_to_tmessage(cls, node: html.HtmlElement, strip_linebreaks) -> TelegramMessage:
|
|
||||||
if node.tag == "blockquote":
|
|
||||||
return cls.blockquote_to_tmessage(node, strip_linebreaks)
|
|
||||||
elif node.tag in ("ol", "ul"):
|
|
||||||
return cls.list_to_tmessage(node, strip_linebreaks)
|
|
||||||
elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
|
||||||
return cls.header_to_tmessage(node, strip_linebreaks)
|
|
||||||
elif node.tag == "br":
|
|
||||||
return TelegramMessage("\n")
|
|
||||||
elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"):
|
|
||||||
return cls.basic_format_to_tmessage(node, strip_linebreaks)
|
|
||||||
elif node.tag == "a":
|
|
||||||
return cls.link_to_tstring(node, strip_linebreaks)
|
|
||||||
elif node.tag == "p":
|
|
||||||
return cls.tag_aware_parse_node(node, strip_linebreaks).append("\n")
|
|
||||||
elif node.tag == "pre":
|
|
||||||
lang = ""
|
|
||||||
try:
|
|
||||||
if node[0].tag == "code":
|
|
||||||
lang = node[0].attrib["class"][len("language-"):]
|
|
||||||
node = node[0]
|
|
||||||
except (IndexError, KeyError):
|
|
||||||
pass
|
|
||||||
return cls.parse_node(node, strip_linebreaks=False).format(Pre, language=lang)
|
|
||||||
elif node.tag == "code":
|
|
||||||
return cls.parse_node(node, strip_linebreaks=False).format(Code)
|
|
||||||
return cls.tag_aware_parse_node(node, strip_linebreaks)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def text_to_tmessage(text: str, strip_linebreaks: bool = True) -> TelegramMessage:
|
|
||||||
if strip_linebreaks:
|
|
||||||
text = text.replace("\n", "")
|
|
||||||
return TelegramMessage(text)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def node_to_tagged_tmessages(cls, node: html.HtmlElement, strip_linebreaks: bool = True
|
|
||||||
) -> List[Tuple[TelegramMessage, str]]:
|
|
||||||
output = []
|
|
||||||
|
|
||||||
if node.text:
|
|
||||||
output.append((cls.text_to_tmessage(node.text, strip_linebreaks), "text"))
|
|
||||||
for child in node:
|
|
||||||
output.append((cls.node_to_tmessage(child, strip_linebreaks), child.tag))
|
|
||||||
if child.tail:
|
|
||||||
output.append((cls.text_to_tmessage(child.tail, strip_linebreaks), "text"))
|
|
||||||
return output
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def node_to_tmessages(cls, node: html.HtmlElement, strip_linebreaks) -> List[TelegramMessage]:
|
|
||||||
return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, strip_linebreaks)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tag_aware_parse_node(cls, node: html.HtmlElement, strip_linebreaks) -> TelegramMessage:
|
|
||||||
msgs = cls.node_to_tagged_tmessages(node, strip_linebreaks)
|
|
||||||
output = TelegramMessage()
|
|
||||||
for msg, tag in msgs:
|
|
||||||
if tag in cls.block_tags:
|
|
||||||
msg = msg.append("\n").prepend("\n")
|
|
||||||
output = output.append(msg)
|
|
||||||
return output.trim()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def parse_node(cls, node: html.HtmlElement, strip_linebreaks) -> TelegramMessage:
|
|
||||||
return TelegramMessage.join(cls.node_to_tmessages(node, strip_linebreaks))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def parse(cls, data: str) -> ParsedMessage:
|
|
||||||
document = html.fromstring(f"<html>{data}</html>")
|
|
||||||
msg = cls.parse_node(document, strip_linebreaks=True)
|
|
||||||
return msg.text, msg.entities
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, List, Tuple, TYPE_CHECKING
|
|
||||||
from html import escape
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName,
|
|
||||||
MessageEntityEmail, MessageEntityUrl, MessageEntityTextUrl,
|
|
||||||
MessageEntityBold, MessageEntityItalic, MessageEntityCode,
|
|
||||||
MessageEntityPre, MessageEntityBotCommand, Message, PeerChannel,
|
|
||||||
MessageEntityHashtag, TypeMessageEntity, MessageFwdHeader, PeerUser)
|
|
||||||
|
|
||||||
from mautrix_appservice import MatrixRequestError
|
|
||||||
from mautrix_appservice.intent_api import IntentAPI
|
|
||||||
|
|
||||||
from .. import user as u, puppet as pu, portal as po
|
|
||||||
from ..db import Message as DBMessage
|
|
||||||
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
|
|
||||||
trim_reply_fallback_text, unicode_to_html)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..abstract_user import AbstractUser
|
|
||||||
from ..context import Context
|
|
||||||
|
|
||||||
try:
|
|
||||||
from lxml.html.diff import htmldiff
|
|
||||||
except ImportError:
|
|
||||||
htmldiff = None # type: function
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger("mau.fmt.tg") # type: logging.Logger
|
|
||||||
should_highlight_edits = False # type: bool
|
|
||||||
|
|
||||||
|
|
||||||
def telegram_reply_to_matrix(evt: Message, source: "AbstractUser") -> dict:
|
|
||||||
if evt.reply_to_msg_id:
|
|
||||||
space = (evt.to_id.channel_id
|
|
||||||
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
|
|
||||||
else source.tgid)
|
|
||||||
msg = DBMessage.query.get((evt.reply_to_msg_id, space))
|
|
||||||
if msg:
|
|
||||||
return {
|
|
||||||
"m.in_reply_to": {
|
|
||||||
"event_id": msg.mxid,
|
|
||||||
"room_id": msg.mx_room,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
async def _add_forward_header(source, text: str, html: Optional[str],
|
|
||||||
fwd_from: MessageFwdHeader) -> Tuple[str, str]:
|
|
||||||
if not html:
|
|
||||||
html = escape(text)
|
|
||||||
fwd_from_html, fwd_from_text = None, None
|
|
||||||
if fwd_from.from_id:
|
|
||||||
user = u.User.get_by_tgid(fwd_from.from_id)
|
|
||||||
if user:
|
|
||||||
fwd_from_text = user.displayname or user.mxid
|
|
||||||
fwd_from_html = f"<a href='https://matrix.to/#/{user.mxid}'>{fwd_from_text}</a>"
|
|
||||||
|
|
||||||
if not fwd_from_text:
|
|
||||||
puppet = pu.Puppet.get(fwd_from.from_id, create=False)
|
|
||||||
if puppet and puppet.displayname:
|
|
||||||
fwd_from_text = puppet.displayname or puppet.mxid
|
|
||||||
fwd_from_html = f"<a href='https://matrix.to/#/{puppet.mxid}'>{fwd_from_text}</a>"
|
|
||||||
|
|
||||||
if not fwd_from_text:
|
|
||||||
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
|
|
||||||
if user:
|
|
||||||
fwd_from_text = pu.Puppet.get_displayname(user, False)
|
|
||||||
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
|
||||||
|
|
||||||
if not fwd_from_text:
|
|
||||||
if fwd_from.from_id:
|
|
||||||
fwd_from_text = "Unknown user"
|
|
||||||
else:
|
|
||||||
fwd_from_text = "Unknown source"
|
|
||||||
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
|
||||||
|
|
||||||
text = "\n".join([f"> {line}" for line in text.split("\n")])
|
|
||||||
text = f"Forwarded from {fwd_from_text}:\n{text}"
|
|
||||||
html = (f"Forwarded message from {fwd_from_html}<br/>"
|
|
||||||
f"<tg-forward><blockquote>{html}</blockquote></tg-forward>")
|
|
||||||
return text, html
|
|
||||||
|
|
||||||
|
|
||||||
def highlight_edits(new_html: str, old_html: str) -> str:
|
|
||||||
# Don't include `Edit:` text in diff.
|
|
||||||
if old_html.startswith("<u>Edit:</u> "):
|
|
||||||
old_html = old_html[len("<u>Edit:</u> "):]
|
|
||||||
|
|
||||||
# Generate diff with lxml
|
|
||||||
new_html = htmldiff(old_html, new_html)
|
|
||||||
|
|
||||||
# Replace <ins> with <u> since Riot doesn't allow <ins>
|
|
||||||
new_html = new_html.replace("<ins>", "<u>").replace("</ins>", "</u>")
|
|
||||||
# Remove <del>s since we just want to hide deletions.
|
|
||||||
new_html = re.sub("<del>.+?</del>", "", new_html)
|
|
||||||
return new_html
|
|
||||||
|
|
||||||
|
|
||||||
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message,
|
|
||||||
relates_to: dict, main_intent: IntentAPI, is_edit: bool
|
|
||||||
) -> Tuple[str, str]:
|
|
||||||
space = (evt.to_id.channel_id
|
|
||||||
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
|
|
||||||
else source.tgid)
|
|
||||||
|
|
||||||
msg = DBMessage.query.get((evt.reply_to_msg_id, space))
|
|
||||||
if not msg:
|
|
||||||
return text, html
|
|
||||||
|
|
||||||
relates_to["m.in_reply_to"] = {
|
|
||||||
"event_id": msg.mxid,
|
|
||||||
"room_id": msg.mx_room,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
event = await main_intent.get_event(msg.mx_room, msg.mxid)
|
|
||||||
|
|
||||||
content = event["content"]
|
|
||||||
r_sender = event["sender"]
|
|
||||||
|
|
||||||
r_text_body = trim_reply_fallback_text(content["body"])
|
|
||||||
r_html_body = trim_reply_fallback_html(content["formatted_body"]
|
|
||||||
if "formatted_body" in content
|
|
||||||
else escape(content["body"]))
|
|
||||||
|
|
||||||
puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
|
|
||||||
r_displayname = puppet.displayname if puppet else r_sender
|
|
||||||
r_sender_link = f"<a href='https://matrix.to/#/{r_sender}'>{r_displayname}</a>"
|
|
||||||
|
|
||||||
if is_edit and should_highlight_edits:
|
|
||||||
html = highlight_edits(html or escape(text), r_html_body)
|
|
||||||
except (ValueError, KeyError, MatrixRequestError):
|
|
||||||
r_sender_link = "unknown user"
|
|
||||||
r_displayname = "unknown user"
|
|
||||||
r_text_body = "Failed to fetch message"
|
|
||||||
r_html_body = "<em>Failed to fetch message</em>"
|
|
||||||
|
|
||||||
if is_edit:
|
|
||||||
html = f"<u>Edit:</u> {html or escape(text)}"
|
|
||||||
text = f"Edit: {text}"
|
|
||||||
|
|
||||||
r_keyword = "In reply to" if not is_edit else "Edit to"
|
|
||||||
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>{r_keyword}</a>"
|
|
||||||
html = (
|
|
||||||
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
|
|
||||||
+ (html or escape(text)))
|
|
||||||
|
|
||||||
lines = r_text_body.strip().split("\n")
|
|
||||||
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
|
|
||||||
for line in lines:
|
|
||||||
if line:
|
|
||||||
text_with_quote += f"\n> {line}"
|
|
||||||
text_with_quote += "\n\n"
|
|
||||||
text_with_quote += text
|
|
||||||
return text_with_quote, html
|
|
||||||
|
|
||||||
|
|
||||||
async def telegram_to_matrix(evt: Message, source: "AbstractUser",
|
|
||||||
main_intent: Optional[IntentAPI] = None,
|
|
||||||
is_edit: bool = False, prefix_text: Optional[str] = None,
|
|
||||||
prefix_html: Optional[str] = None) -> Tuple[str, str, dict]:
|
|
||||||
text = add_surrogates(evt.message)
|
|
||||||
html = _telegram_entities_to_matrix_catch(text, evt.entities) if evt.entities else None
|
|
||||||
relates_to = {}
|
|
||||||
|
|
||||||
if prefix_html:
|
|
||||||
html = prefix_html + (html or escape(text))
|
|
||||||
if prefix_text:
|
|
||||||
text = prefix_text + text
|
|
||||||
|
|
||||||
if evt.fwd_from:
|
|
||||||
text, html = await _add_forward_header(source, text, html, evt.fwd_from)
|
|
||||||
|
|
||||||
if evt.reply_to_msg_id:
|
|
||||||
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent,
|
|
||||||
is_edit)
|
|
||||||
|
|
||||||
if isinstance(evt, Message) and evt.post and evt.post_author:
|
|
||||||
if not html:
|
|
||||||
html = escape(text)
|
|
||||||
text += f"\n- {evt.post_author}"
|
|
||||||
html += f"<br/><i>- <u>{evt.post_author}</u></i>"
|
|
||||||
|
|
||||||
html = unicode_to_html(text, html, "\u0336", "del")
|
|
||||||
html = unicode_to_html(text, html, "\u0332", "u")
|
|
||||||
|
|
||||||
if html:
|
|
||||||
html = html.replace("\n", "<br/>")
|
|
||||||
|
|
||||||
return remove_surrogates(text), remove_surrogates(html), relates_to
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
|
|
||||||
try:
|
|
||||||
return _telegram_entities_to_matrix(text, entities)
|
|
||||||
except Exception:
|
|
||||||
log.exception("Failed to convert Telegram format:\n"
|
|
||||||
"message=%s\n"
|
|
||||||
"entities=%s",
|
|
||||||
text, entities)
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity]) -> str:
|
|
||||||
if not entities:
|
|
||||||
return text
|
|
||||||
html = []
|
|
||||||
last_offset = 0
|
|
||||||
for entity in entities:
|
|
||||||
if entity.offset > last_offset:
|
|
||||||
html.append(escape(text[last_offset:entity.offset]))
|
|
||||||
elif entity.offset < last_offset:
|
|
||||||
continue
|
|
||||||
|
|
||||||
skip_entity = False
|
|
||||||
entity_text = escape(text[entity.offset:entity.offset + entity.length])
|
|
||||||
entity_type = type(entity)
|
|
||||||
|
|
||||||
if entity_type == MessageEntityBold:
|
|
||||||
html.append(f"<strong>{entity_text}</strong>")
|
|
||||||
elif entity_type == MessageEntityItalic:
|
|
||||||
html.append(f"<em>{entity_text}</em>")
|
|
||||||
elif entity_type == MessageEntityCode:
|
|
||||||
html.append(f"<code>{entity_text}</code>")
|
|
||||||
elif entity_type == MessageEntityPre:
|
|
||||||
skip_entity = _parse_pre(html, entity_text, entity.language)
|
|
||||||
elif entity_type == MessageEntityMention:
|
|
||||||
skip_entity = _parse_mention(html, entity_text)
|
|
||||||
elif entity_type == MessageEntityMentionName:
|
|
||||||
skip_entity = _parse_name_mention(html, entity_text, entity.user_id)
|
|
||||||
elif entity_type == MessageEntityEmail:
|
|
||||||
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
|
|
||||||
elif entity_type in {MessageEntityTextUrl, MessageEntityUrl}:
|
|
||||||
skip_entity = _parse_url(html, entity_text,
|
|
||||||
entity.url if entity_type == MessageEntityTextUrl else None)
|
|
||||||
elif entity_type == MessageEntityBotCommand:
|
|
||||||
html.append(f"<font color='blue'>!{entity_text[1:]}</font>")
|
|
||||||
elif entity_type == MessageEntityHashtag:
|
|
||||||
html.append(f"<font color='blue'>{entity_text}</font>")
|
|
||||||
else:
|
|
||||||
skip_entity = True
|
|
||||||
last_offset = entity.offset + (0 if skip_entity else entity.length)
|
|
||||||
html.append(text[last_offset:])
|
|
||||||
|
|
||||||
return "".join(html)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_pre(html: List[str], entity_text: str, language: str) -> bool:
|
|
||||||
if language:
|
|
||||||
html.append("<pre>"
|
|
||||||
f"<code class='language-{language}'>{entity_text}</code>"
|
|
||||||
"</pre>")
|
|
||||||
else:
|
|
||||||
html.append(f"<pre><code>{entity_text}</code></pre>")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_mention(html: List[str], entity_text: str) -> bool:
|
|
||||||
username = entity_text[1:]
|
|
||||||
|
|
||||||
user = u.User.find_by_username(username) or pu.Puppet.find_by_username(username)
|
|
||||||
if user:
|
|
||||||
mxid = user.mxid
|
|
||||||
else:
|
|
||||||
portal = po.Portal.find_by_username(username)
|
|
||||||
mxid = portal.alias or portal.mxid if portal else None
|
|
||||||
|
|
||||||
if mxid:
|
|
||||||
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_name_mention(html: List[str], entity_text: str, user_id: int) -> bool:
|
|
||||||
user = u.User.get_by_tgid(user_id)
|
|
||||||
if user:
|
|
||||||
mxid = user.mxid
|
|
||||||
else:
|
|
||||||
puppet = pu.Puppet.get(user_id, create=False)
|
|
||||||
mxid = puppet.mxid if puppet else None
|
|
||||||
if mxid:
|
|
||||||
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
message_link_regex = re.compile(
|
|
||||||
r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
|
|
||||||
url = escape(url) if url else entity_text
|
|
||||||
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
|
|
||||||
url = "http://" + url
|
|
||||||
|
|
||||||
message_link_match = message_link_regex.match(url)
|
|
||||||
if message_link_match:
|
|
||||||
group, msgid = message_link_match.groups()
|
|
||||||
msgid = int(msgid)
|
|
||||||
|
|
||||||
portal = po.Portal.find_by_username(group)
|
|
||||||
if portal:
|
|
||||||
message = DBMessage.query.get((msgid, portal.tgid))
|
|
||||||
if message:
|
|
||||||
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
|
|
||||||
|
|
||||||
html.append(f"<a href='{url}'>{entity_text}</a>")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def init_tg(context: "Context"):
|
|
||||||
global should_highlight_edits
|
|
||||||
should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"]
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, Pattern
|
|
||||||
from html import escape
|
|
||||||
import struct
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon.
|
|
||||||
# Licensed under the MIT license.
|
|
||||||
# https://github.com/LonamiWebs/Telethon/blob/master/telethon/extensions/markdown.py
|
|
||||||
def add_surrogates(text: Optional[str]) -> Optional[str]:
|
|
||||||
if text is None:
|
|
||||||
return None
|
|
||||||
return "".join("".join(chr(y) for y in struct.unpack("<HH", x.encode("utf-16-le")))
|
|
||||||
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_surrogates(text: Optional[str]) -> Optional[str]:
|
|
||||||
if text is None:
|
|
||||||
return None
|
|
||||||
return text.encode("utf-16", "surrogatepass").decode("utf-16")
|
|
||||||
|
|
||||||
|
|
||||||
def trim_reply_fallback_text(text: str) -> str:
|
|
||||||
if not text.startswith("> ") or "\n" not in text:
|
|
||||||
return text
|
|
||||||
lines = text.split("\n")
|
|
||||||
while len(lines) > 0 and lines[0].startswith("> "):
|
|
||||||
lines.pop(0)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
html_reply_fallback_regex = re.compile("^<mx-reply>"
|
|
||||||
r"[\s\S]+?"
|
|
||||||
"</mx-reply>") # type: Pattern
|
|
||||||
|
|
||||||
|
|
||||||
def trim_reply_fallback_html(html: str) -> str:
|
|
||||||
return html_reply_fallback_regex.sub("", html)
|
|
||||||
|
|
||||||
|
|
||||||
def unicode_to_html(text: str, html: str, ctrl: str, tag: str) -> str:
|
|
||||||
if ctrl not in text:
|
|
||||||
return html
|
|
||||||
if not html:
|
|
||||||
html = escape(text)
|
|
||||||
tag_start = f"<{tag}>"
|
|
||||||
tag_end = f"</{tag}>"
|
|
||||||
characters = html.split(ctrl)
|
|
||||||
html = ""
|
|
||||||
in_tag = False
|
|
||||||
for char in characters:
|
|
||||||
if not in_tag:
|
|
||||||
if len(char) > 1:
|
|
||||||
html += char[0:-1]
|
|
||||||
char = char[-1]
|
|
||||||
html += tag_start
|
|
||||||
in_tag = True
|
|
||||||
html += char
|
|
||||||
else:
|
|
||||||
if len(char) > 1:
|
|
||||||
html += tag_end
|
|
||||||
in_tag = False
|
|
||||||
html += char
|
|
||||||
if in_tag:
|
|
||||||
html += tag_end
|
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
def html_to_unicode(text: str, ctrl: str) -> str:
|
|
||||||
return ctrl.join(text) + ctrl
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import List, Dict, Tuple, Set, Match
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import re
|
|
||||||
|
|
||||||
from mautrix_appservice import MatrixRequestError, IntentError
|
|
||||||
|
|
||||||
from . import user as u, portal as po, puppet as pu, commands as com
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixHandler:
|
|
||||||
log = logging.getLogger("mau.mx") # type: logging.Logger
|
|
||||||
|
|
||||||
def __init__(self, context):
|
|
||||||
self.az, self.db, self.config, _, self.tgbot = context
|
|
||||||
self.commands = com.CommandProcessor(context) # type: com.CommandProcessor
|
|
||||||
self.previously_typing = [] # type: List[str]
|
|
||||||
|
|
||||||
self.az.matrix_event_handler(self.handle_event)
|
|
||||||
|
|
||||||
async def init_as_bot(self):
|
|
||||||
displayname = self.config["appservice.bot_displayname"]
|
|
||||||
if displayname:
|
|
||||||
try:
|
|
||||||
await self.az.intent.set_display_name(
|
|
||||||
displayname if displayname != "remove" else "")
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
self.log.exception("TimeoutError when trying to set displayname")
|
|
||||||
|
|
||||||
avatar = self.config["appservice.bot_avatar"]
|
|
||||||
if avatar:
|
|
||||||
try:
|
|
||||||
await self.az.intent.set_avatar(avatar if avatar != "remove" else "")
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
self.log.exception("TimeoutError when trying to set avatar")
|
|
||||||
|
|
||||||
async def handle_puppet_invite(self, room_id, puppet: pu.Puppet, inviter: u.User):
|
|
||||||
intent = puppet.default_mxid_intent
|
|
||||||
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}")
|
|
||||||
if not await inviter.is_logged_in():
|
|
||||||
await intent.error_and_leave(
|
|
||||||
room_id, text="Please log in before inviting Telegram puppets.")
|
|
||||||
return
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if portal:
|
|
||||||
if portal.peer_type == "user":
|
|
||||||
await intent.error_and_leave(
|
|
||||||
room_id, text="You can not invite additional users to private chats.")
|
|
||||||
return
|
|
||||||
await portal.invite_telegram(inviter, puppet)
|
|
||||||
await intent.join_room(room_id)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
members = await self.az.intent.get_room_members(room_id)
|
|
||||||
except MatrixRequestError:
|
|
||||||
members = []
|
|
||||||
if self.az.bot_mxid not in members:
|
|
||||||
if len(members) > 1:
|
|
||||||
await intent.error_and_leave(room_id, text=None, html=(
|
|
||||||
f"Please invite "
|
|
||||||
f"<a href='https://matrix.to/#/{self.az.bot_mxid}'>the bridge bot</a> "
|
|
||||||
f"first if you want to create a Telegram chat."))
|
|
||||||
return
|
|
||||||
|
|
||||||
await intent.join_room(room_id)
|
|
||||||
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
|
|
||||||
if portal.mxid:
|
|
||||||
try:
|
|
||||||
await intent.invite(portal.mxid, inviter.mxid)
|
|
||||||
await intent.send_notice(room_id, text=None, html=(
|
|
||||||
"You already have a private chat with me: "
|
|
||||||
f"<a href='https://matrix.to/#/{portal.mxid}'>"
|
|
||||||
"Link to room"
|
|
||||||
"</a>"))
|
|
||||||
await intent.leave_room(room_id)
|
|
||||||
return
|
|
||||||
except MatrixRequestError:
|
|
||||||
pass
|
|
||||||
portal.mxid = room_id
|
|
||||||
portal.save()
|
|
||||||
inviter.register_portal(portal)
|
|
||||||
await intent.send_notice(room_id, "po.Portal to private chat created.")
|
|
||||||
else:
|
|
||||||
await intent.join_room(room_id)
|
|
||||||
await intent.send_notice(room_id, "This puppet will remain inactive until a "
|
|
||||||
"Telegram chat is created for this room.")
|
|
||||||
|
|
||||||
async def accept_bot_invite(self, room_id: str, inviter: u.User):
|
|
||||||
tries = 0
|
|
||||||
while tries < 5:
|
|
||||||
try:
|
|
||||||
await self.az.intent.join_room(room_id)
|
|
||||||
break
|
|
||||||
except (IntentError, MatrixRequestError):
|
|
||||||
tries += 1
|
|
||||||
wait_for_seconds = (tries + 1) * 10
|
|
||||||
if tries < 5:
|
|
||||||
self.log.exception(f"Failed to join room {room_id} with bridge bot, "
|
|
||||||
f"retrying in {wait_for_seconds} seconds...")
|
|
||||||
await asyncio.sleep(wait_for_seconds)
|
|
||||||
else:
|
|
||||||
self.log.exception("Failed to join room {room}, giving up.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not inviter.whitelisted:
|
|
||||||
await self.az.intent.send_notice(
|
|
||||||
room_id, text=None,
|
|
||||||
html="You are not whitelisted to use this bridge.<br/><br/>"
|
|
||||||
"If you are the owner of this bridge, see the "
|
|
||||||
"<code>bridge.permissions</code> section in your config file.")
|
|
||||||
await self.az.intent.leave_room(room_id)
|
|
||||||
|
|
||||||
async def handle_invite(self, room_id: str, user_id: str, inviter_mxid: str):
|
|
||||||
self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}")
|
|
||||||
inviter = await u.User.get_by_mxid(inviter_mxid).ensure_started()
|
|
||||||
if user_id == self.az.bot_mxid:
|
|
||||||
return await self.accept_bot_invite(room_id, inviter)
|
|
||||||
elif not inviter.whitelisted:
|
|
||||||
return
|
|
||||||
|
|
||||||
puppet = pu.Puppet.get_by_mxid(user_id)
|
|
||||||
if puppet:
|
|
||||||
await self.handle_puppet_invite(room_id, puppet, inviter)
|
|
||||||
return
|
|
||||||
|
|
||||||
user = u.User.get_by_mxid(user_id, create=False)
|
|
||||||
if not user:
|
|
||||||
return
|
|
||||||
await user.ensure_started()
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if user and await user.has_full_access(allow_bot=True) and portal:
|
|
||||||
await portal.invite_telegram(inviter, user)
|
|
||||||
return
|
|
||||||
|
|
||||||
# The rest can probably be ignored
|
|
||||||
|
|
||||||
async def handle_join(self, room_id: str, user_id: str, event_id: str):
|
|
||||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not user.relaybot_whitelisted:
|
|
||||||
await portal.main_intent.kick(room_id, user.mxid,
|
|
||||||
"You are not whitelisted on this Telegram bridge.")
|
|
||||||
return
|
|
||||||
elif not await user.is_logged_in() and not portal.has_bot:
|
|
||||||
await portal.main_intent.kick(room_id, user.mxid,
|
|
||||||
"This chat does not have a bot relaying "
|
|
||||||
"messages for unauthenticated users.")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.log.debug(f"{user} joined {room_id}")
|
|
||||||
if await user.is_logged_in() or portal.has_bot:
|
|
||||||
await portal.join_matrix(user, event_id)
|
|
||||||
|
|
||||||
async def handle_part(self, room_id: str, user_id, sender_mxid: str, event_id: str):
|
|
||||||
self.log.debug(f"{user_id} left {room_id}")
|
|
||||||
|
|
||||||
sender = u.User.get_by_mxid(sender_mxid, create=False)
|
|
||||||
if not sender:
|
|
||||||
return
|
|
||||||
await sender.ensure_started()
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
|
|
||||||
puppet = pu.Puppet.get_by_mxid(user_id)
|
|
||||||
if sender and puppet:
|
|
||||||
await portal.leave_matrix(puppet, sender, event_id)
|
|
||||||
|
|
||||||
user = u.User.get_by_mxid(user_id, create=False)
|
|
||||||
if not user:
|
|
||||||
return
|
|
||||||
await user.ensure_started()
|
|
||||||
if await user.is_logged_in() or portal.has_bot:
|
|
||||||
await portal.leave_matrix(user, sender, event_id)
|
|
||||||
|
|
||||||
def is_command(self, message: dict) -> Tuple[bool, str]:
|
|
||||||
text = message.get("body", "")
|
|
||||||
prefix = self.config["bridge.command_prefix"]
|
|
||||||
is_command = text.startswith(prefix)
|
|
||||||
if is_command:
|
|
||||||
text = text[len(prefix) + 1:]
|
|
||||||
return is_command, text
|
|
||||||
|
|
||||||
async def handle_message(self, room, sender, message, event_id):
|
|
||||||
is_command, text = self.is_command(message)
|
|
||||||
sender = await u.User.get_by_mxid(sender).ensure_started()
|
|
||||||
if not sender.relaybot_whitelisted:
|
|
||||||
self.log.debug(f"Ignoring message \"{message}\" from {sender} to {room}:"
|
|
||||||
" u.User is not whitelisted.")
|
|
||||||
return
|
|
||||||
self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}")
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_mxid(room)
|
|
||||||
if not is_command and portal and (await sender.is_logged_in() or portal.has_bot):
|
|
||||||
await portal.handle_matrix_message(sender, message, event_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text":
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
is_management = len(await self.az.intent.get_room_members(room)) == 2
|
|
||||||
except MatrixRequestError:
|
|
||||||
# The AS bot is not in the room.
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_command or is_management:
|
|
||||||
try:
|
|
||||||
command, arguments = text.split(" ", 1)
|
|
||||||
args = arguments.split(" ")
|
|
||||||
except ValueError:
|
|
||||||
# Not enough values to unpack, i.e. no arguments
|
|
||||||
command = text
|
|
||||||
args = []
|
|
||||||
await self.commands.handle(room, sender, command, args, is_management,
|
|
||||||
is_portal=portal is not None)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_redaction(room_id: str, sender_mxid: str, event_id: str):
|
|
||||||
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
|
||||||
if not sender.relaybot_whitelisted:
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
|
|
||||||
await portal.handle_matrix_deletion(sender, event_id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_power_levels(room_id: str, sender_mxid: str, new: dict, old: dict):
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
|
||||||
if await sender.has_full_access(allow_bot=True) and portal:
|
|
||||||
await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_room_meta(evt_type: str, room_id: str, sender_mxid: str, content: dict):
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
|
||||||
if await sender.has_full_access(allow_bot=True) and portal:
|
|
||||||
handler, content_key = {
|
|
||||||
"m.room.name": (portal.handle_matrix_title, "name"),
|
|
||||||
"m.room.topic": (portal.handle_matrix_about, "topic"),
|
|
||||||
"m.room.avatar": (portal.handle_matrix_avatar, "url"),
|
|
||||||
}[evt_type]
|
|
||||||
if content_key not in content:
|
|
||||||
return
|
|
||||||
await handler(sender, content[content_key])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_room_pin(room_id: str, sender_mxid: str, new_events: Set[str],
|
|
||||||
old_events: Set[str]):
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
|
||||||
if await sender.has_full_access(allow_bot=True) and portal:
|
|
||||||
events = new_events - old_events
|
|
||||||
if len(events) > 0:
|
|
||||||
# New event pinned, set that as pinned in Telegram.
|
|
||||||
await portal.handle_matrix_pin(sender, events.pop())
|
|
||||||
elif len(new_events) == 0:
|
|
||||||
# All pinned events removed, remove pinned event in Telegram.
|
|
||||||
await portal.handle_matrix_pin(sender, None)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_name_change(room_id: str, user_id: str, displayname: str,
|
|
||||||
prev_displayname: str, event_id: str):
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal or not portal.has_bot:
|
|
||||||
return
|
|
||||||
|
|
||||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
|
||||||
if await user.needs_relaybot(portal):
|
|
||||||
await portal.name_change_matrix(user, displayname, prev_displayname, event_id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_read_receipts(content: dict) -> Dict[str, str]:
|
|
||||||
return {user_id: event_id
|
|
||||||
for event_id, receipts in content.items()
|
|
||||||
for user_id in receipts.get("m.read", {})}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_read_receipts(room_id: str, receipts: Dict[str, str]):
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
|
|
||||||
for user_id, event_id in receipts.items():
|
|
||||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
|
||||||
if not await user.is_logged_in():
|
|
||||||
continue
|
|
||||||
await portal.mark_read(user, event_id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_presence(user_id: str, presence: str):
|
|
||||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
|
||||||
if not await user.is_logged_in():
|
|
||||||
return
|
|
||||||
await user.set_presence(presence == "online")
|
|
||||||
|
|
||||||
async def handle_typing(self, room_id: str, now_typing: List[str]):
|
|
||||||
portal = po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
|
|
||||||
for user_id in set(self.previously_typing + now_typing):
|
|
||||||
is_typing = user_id in now_typing
|
|
||||||
was_typing = user_id in self.previously_typing
|
|
||||||
if is_typing and was_typing:
|
|
||||||
continue
|
|
||||||
|
|
||||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
|
||||||
if not await user.is_logged_in():
|
|
||||||
continue
|
|
||||||
|
|
||||||
await portal.set_typing(user, is_typing)
|
|
||||||
|
|
||||||
self.previously_typing = now_typing
|
|
||||||
|
|
||||||
def filter_matrix_event(self, event: dict):
|
|
||||||
sender = event.get("sender", None)
|
|
||||||
if not sender:
|
|
||||||
return False
|
|
||||||
return (sender == self.az.bot_mxid
|
|
||||||
or pu.Puppet.get_id_from_mxid(sender) is not None)
|
|
||||||
|
|
||||||
async def try_handle_event(self, evt: dict):
|
|
||||||
try:
|
|
||||||
await self.handle_event(evt)
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Error handling manually received Matrix event")
|
|
||||||
|
|
||||||
async def handle_event(self, evt: dict):
|
|
||||||
if self.filter_matrix_event(evt):
|
|
||||||
return
|
|
||||||
self.log.debug("Received event: %s", evt)
|
|
||||||
evt_type = evt.get("type", "m.unknown") # type: str
|
|
||||||
room_id = evt.get("room_id", None) # type: str
|
|
||||||
event_id = evt.get("event_id", None) # type: str
|
|
||||||
sender = evt.get("sender", None) # type: str
|
|
||||||
content = evt.get("content", {}) # type: dict
|
|
||||||
if evt_type == "m.room.member":
|
|
||||||
state_key = evt["state_key"] # type: str
|
|
||||||
prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: dict
|
|
||||||
membership = content.get("membership", "") # type: str
|
|
||||||
prev_membership = prev_content.get("membership", "leave") # type: str
|
|
||||||
if membership == prev_membership:
|
|
||||||
match = re.compile("@(.+):(.+)").match(state_key) # type: Match
|
|
||||||
localpart = match.group(1) # type: str
|
|
||||||
displayname = content.get("displayname", localpart) # type: str
|
|
||||||
prev_displayname = prev_content.get("displayname", localpart) # type: str
|
|
||||||
if displayname != prev_displayname:
|
|
||||||
await self.handle_name_change(room_id, state_key, displayname,
|
|
||||||
prev_displayname, event_id)
|
|
||||||
elif membership == "invite":
|
|
||||||
await self.handle_invite(room_id, state_key, sender)
|
|
||||||
elif prev_membership == "join" and membership == "leave":
|
|
||||||
await self.handle_part(room_id, state_key, sender, event_id)
|
|
||||||
elif membership == "join":
|
|
||||||
await self.handle_join(room_id, state_key, event_id)
|
|
||||||
elif evt_type in ("m.room.message", "m.sticker"):
|
|
||||||
if evt_type != "m.room.message":
|
|
||||||
content["msgtype"] = evt_type
|
|
||||||
await self.handle_message(room_id, sender, content, event_id)
|
|
||||||
elif evt_type == "m.room.redaction":
|
|
||||||
await self.handle_redaction(room_id, sender, evt["redacts"])
|
|
||||||
elif evt_type == "m.room.power_levels":
|
|
||||||
prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: dict
|
|
||||||
await self.handle_power_levels(room_id, sender, evt["content"], prev_content)
|
|
||||||
elif evt_type in ("m.room.name", "m.room.avatar", "m.room.topic"):
|
|
||||||
await self.handle_room_meta(evt_type, room_id, sender, evt["content"])
|
|
||||||
elif evt_type == "m.room.pinned_events":
|
|
||||||
new_events = set(evt["content"]["pinned"])
|
|
||||||
try:
|
|
||||||
old_events = set(evt["unsigned"]["prev_content"]["pinned"])
|
|
||||||
except KeyError:
|
|
||||||
old_events = set()
|
|
||||||
await self.handle_room_pin(room_id, sender, new_events, old_events)
|
|
||||||
elif evt_type == "m.receipt":
|
|
||||||
await self.handle_read_receipts(room_id, self.parse_read_receipts(content))
|
|
||||||
elif evt_type == "m.presence":
|
|
||||||
await self.handle_presence(sender, content.get("presence", "offline"))
|
|
||||||
elif evt_type == "m.typing":
|
|
||||||
await self.handle_typing(room_id, content.get("user_ids", []))
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,456 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, Awaitable, Pattern, Dict, List, TYPE_CHECKING
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from sqlalchemy import orm
|
|
||||||
|
|
||||||
from telethon.tl.types import UserProfilePhoto
|
|
||||||
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
|
|
||||||
|
|
||||||
from .db import Puppet as DBPuppet
|
|
||||||
from . import util
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .matrix import MatrixHandler
|
|
||||||
from .config import Config
|
|
||||||
from .context import Context
|
|
||||||
|
|
||||||
config = None # type: Config
|
|
||||||
|
|
||||||
|
|
||||||
class Puppet:
|
|
||||||
log = logging.getLogger("mau.puppet") # type: logging.Logger
|
|
||||||
db = None # type: orm.Session
|
|
||||||
az = None # type: AppService
|
|
||||||
mx = None # type: MatrixHandler
|
|
||||||
loop = None # type: asyncio.AbstractEventLoop
|
|
||||||
mxid_regex = None # type: Pattern
|
|
||||||
username_template = None # type: str
|
|
||||||
hs_domain = None # type: str
|
|
||||||
cache = {} # type: Dict[str, Puppet]
|
|
||||||
by_custom_mxid = {} # type: Dict[str, Puppet]
|
|
||||||
|
|
||||||
def __init__(self, id=None, access_token=None, custom_mxid=None, username=None,
|
|
||||||
displayname=None, displayname_source=None, photo_id=None, is_bot=None,
|
|
||||||
is_registered=False, db_instance=None):
|
|
||||||
self.id = id
|
|
||||||
self.access_token = access_token
|
|
||||||
self.custom_mxid = custom_mxid
|
|
||||||
self.is_real_user = self.custom_mxid and self.access_token
|
|
||||||
self.default_mxid = self.get_mxid_from_id(self.id)
|
|
||||||
self.mxid = self.custom_mxid or self.default_mxid
|
|
||||||
|
|
||||||
self.username = username
|
|
||||||
self.displayname = displayname
|
|
||||||
self.displayname_source = displayname_source
|
|
||||||
self.photo_id = photo_id
|
|
||||||
self.is_bot = is_bot
|
|
||||||
self.is_registered = is_registered
|
|
||||||
self._db_instance = db_instance
|
|
||||||
|
|
||||||
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
|
|
||||||
self.intent = None # type: IntentAPI
|
|
||||||
self.refresh_intents()
|
|
||||||
|
|
||||||
self.cache[id] = self
|
|
||||||
if self.custom_mxid:
|
|
||||||
self.by_custom_mxid[self.custom_mxid] = self
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tgid(self):
|
|
||||||
return self.id
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def is_logged_in():
|
|
||||||
return True
|
|
||||||
|
|
||||||
# region Custom puppet management
|
|
||||||
def refresh_intents(self):
|
|
||||||
self.is_real_user = self.custom_mxid and self.access_token
|
|
||||||
self.intent = (self.az.intent.user(self.custom_mxid, self.access_token)
|
|
||||||
if self.is_real_user else self.default_mxid_intent)
|
|
||||||
|
|
||||||
async def switch_mxid(self, access_token, mxid):
|
|
||||||
prev_mxid = self.custom_mxid
|
|
||||||
self.custom_mxid = mxid
|
|
||||||
self.access_token = access_token
|
|
||||||
self.refresh_intents()
|
|
||||||
|
|
||||||
err = await self.init_custom_mxid()
|
|
||||||
if err != 0:
|
|
||||||
return err
|
|
||||||
|
|
||||||
try:
|
|
||||||
del self.by_custom_mxid[prev_mxid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
self.mxid = self.custom_mxid or self.default_mxid
|
|
||||||
if self.mxid != self.default_mxid:
|
|
||||||
self.by_custom_mxid[self.mxid] = self
|
|
||||||
await self.leave_rooms_with_default_user()
|
|
||||||
self.save()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def init_custom_mxid(self):
|
|
||||||
if not self.is_real_user:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
mxid = await self.intent.whoami()
|
|
||||||
if not mxid or mxid != self.custom_mxid:
|
|
||||||
self.custom_mxid = None
|
|
||||||
self.access_token = None
|
|
||||||
self.refresh_intents()
|
|
||||||
if mxid != self.custom_mxid:
|
|
||||||
return 2
|
|
||||||
return 1
|
|
||||||
if config["bridge.sync_with_custom_puppets"]:
|
|
||||||
asyncio.ensure_future(self.sync(), loop=self.loop)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def leave_rooms_with_default_user(self):
|
|
||||||
for room_id in await self.default_mxid_intent.get_joined_rooms():
|
|
||||||
try:
|
|
||||||
await self.default_mxid_intent.leave_room(room_id)
|
|
||||||
await self.intent.ensure_joined(room_id)
|
|
||||||
except (IntentError, MatrixRequestError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def create_sync_filter(self) -> Awaitable[str]:
|
|
||||||
return self.intent.client.create_filter(self.custom_mxid, {
|
|
||||||
"room": {
|
|
||||||
"include_leave": False,
|
|
||||||
"state": {
|
|
||||||
"types": []
|
|
||||||
},
|
|
||||||
"timeline": {
|
|
||||||
"types": [],
|
|
||||||
},
|
|
||||||
"ephemeral": {
|
|
||||||
"types": ["m.typing", "m.receipt"],
|
|
||||||
},
|
|
||||||
"account_data": {
|
|
||||||
"types": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"account_data": {
|
|
||||||
"types": [],
|
|
||||||
},
|
|
||||||
"presence": {
|
|
||||||
"types": ["m.presence"],
|
|
||||||
"senders": [self.custom_mxid],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
def filter_events(self, events):
|
|
||||||
new_events = []
|
|
||||||
for event in events:
|
|
||||||
evt_type = event.get("type", None)
|
|
||||||
event.setdefault("content", {})
|
|
||||||
if evt_type == "m.typing":
|
|
||||||
is_typing = self.custom_mxid in event["content"].get("user_ids", [])
|
|
||||||
event["content"]["user_ids"] = [self.custom_mxid] if is_typing else []
|
|
||||||
elif evt_type == "m.receipt":
|
|
||||||
val = None
|
|
||||||
evt = None
|
|
||||||
for event_id in event["content"]:
|
|
||||||
try:
|
|
||||||
val = event["content"][event_id]["m.read"][self.custom_mxid]
|
|
||||||
evt = event_id
|
|
||||||
break
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
if val and evt:
|
|
||||||
event["content"] = {evt: {"m.read": {
|
|
||||||
self.custom_mxid: val
|
|
||||||
}}}
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
new_events.append(event)
|
|
||||||
return new_events
|
|
||||||
|
|
||||||
def handle_sync(self, presence, ephemeral):
|
|
||||||
presence = [self.mx.try_handle_event(event) for event in presence]
|
|
||||||
|
|
||||||
for room_id, events in ephemeral.items():
|
|
||||||
for event in events:
|
|
||||||
event["room_id"] = room_id
|
|
||||||
|
|
||||||
ephemeral = [self.mx.try_handle_event(event)
|
|
||||||
for events in ephemeral.values()
|
|
||||||
for event in self.filter_events(events)]
|
|
||||||
|
|
||||||
events = ephemeral + presence
|
|
||||||
coro = asyncio.gather(*events, loop=self.loop)
|
|
||||||
asyncio.ensure_future(coro, loop=self.loop)
|
|
||||||
|
|
||||||
async def sync(self):
|
|
||||||
try:
|
|
||||||
await self._sync()
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Fatal error syncing")
|
|
||||||
|
|
||||||
async def _sync(self):
|
|
||||||
if not self.is_real_user:
|
|
||||||
self.log.warning("Called sync() for non-custom puppet.")
|
|
||||||
return
|
|
||||||
custom_mxid = self.custom_mxid
|
|
||||||
access_token_at_start = self.access_token
|
|
||||||
errors = 0
|
|
||||||
next_batch = None
|
|
||||||
filter_id = await self.create_sync_filter()
|
|
||||||
self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.")
|
|
||||||
while access_token_at_start == self.access_token:
|
|
||||||
try:
|
|
||||||
sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch,
|
|
||||||
set_presence="offline")
|
|
||||||
errors = 0
|
|
||||||
if next_batch is not None:
|
|
||||||
presence = sync_resp.get("presence", {}).get("events", [])
|
|
||||||
ephemeral = {room: data.get("ephemeral", {}).get("events", [])
|
|
||||||
for room, data
|
|
||||||
in sync_resp.get("rooms", {}).get("join", {}).items()}
|
|
||||||
self.handle_sync(presence, ephemeral)
|
|
||||||
next_batch = sync_resp.get("next_batch", None)
|
|
||||||
except MatrixRequestError as e:
|
|
||||||
wait = min(errors, 11) ** 2
|
|
||||||
self.log.warning(f"Syncer for {custom_mxid} errored: {e}. "
|
|
||||||
f"Waiting for {wait} seconds...")
|
|
||||||
errors += 1
|
|
||||||
await asyncio.sleep(wait)
|
|
||||||
self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.")
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
# region DB conversion
|
|
||||||
|
|
||||||
@property
|
|
||||||
def db_instance(self):
|
|
||||||
if not self._db_instance:
|
|
||||||
self._db_instance = self.new_db_instance()
|
|
||||||
return self._db_instance
|
|
||||||
|
|
||||||
def new_db_instance(self):
|
|
||||||
return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid,
|
|
||||||
username=self.username, displayname=self.displayname,
|
|
||||||
displayname_source=self.displayname_source, photo_id=self.photo_id,
|
|
||||||
is_bot=self.is_bot, matrix_registered=self.is_registered)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_db(cls, db_puppet):
|
|
||||||
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
|
|
||||||
db_puppet.username, db_puppet.displayname, db_puppet.displayname_source,
|
|
||||||
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
|
|
||||||
db_instance=db_puppet)
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
self.db_instance.access_token = self.access_token
|
|
||||||
self.db_instance.custom_mxid = self.custom_mxid
|
|
||||||
self.db_instance.username = self.username
|
|
||||||
self.db_instance.displayname = self.displayname
|
|
||||||
self.db_instance.displayname_source = self.displayname_source
|
|
||||||
self.db_instance.photo_id = self.photo_id
|
|
||||||
self.db_instance.is_bot = self.is_bot
|
|
||||||
self.db_instance.matrix_registered = self.is_registered
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
# region Info updating
|
|
||||||
def similarity(self, query):
|
|
||||||
username_similarity = (SequenceMatcher(None, self.username, query).ratio()
|
|
||||||
if self.username else 0)
|
|
||||||
displayname_similarity = (SequenceMatcher(None, self.displayname, query).ratio()
|
|
||||||
if self.displayname else 0)
|
|
||||||
similarity = max(username_similarity, displayname_similarity)
|
|
||||||
return round(similarity * 1000) / 10
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_displayname(info, enable_format=True):
|
|
||||||
data = {
|
|
||||||
"phone number": info.phone if hasattr(info, "phone") else None,
|
|
||||||
"username": info.username,
|
|
||||||
"full name": " ".join([info.first_name or "", info.last_name or ""]).strip(),
|
|
||||||
"full name reversed": " ".join([info.first_name or "", info.last_name or ""]).strip(),
|
|
||||||
"first name": info.first_name,
|
|
||||||
"last name": info.last_name,
|
|
||||||
}
|
|
||||||
preferences = config.get("bridge.displayname_preference",
|
|
||||||
["full name", "username", "phone"])
|
|
||||||
name = None
|
|
||||||
for preference in preferences:
|
|
||||||
name = data[preference]
|
|
||||||
if name:
|
|
||||||
break
|
|
||||||
|
|
||||||
if info.deleted:
|
|
||||||
name = f"Deleted account {info.id}"
|
|
||||||
elif not name:
|
|
||||||
name = info.id
|
|
||||||
|
|
||||||
if not enable_format:
|
|
||||||
return name
|
|
||||||
return config.get("bridge.displayname_template", "{displayname} (Telegram)").format(
|
|
||||||
displayname=name)
|
|
||||||
|
|
||||||
async def update_info(self, source, info):
|
|
||||||
changed = False
|
|
||||||
if self.username != info.username:
|
|
||||||
self.username = info.username
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
changed = await self.update_displayname(source, info) or changed
|
|
||||||
if isinstance(info.photo, UserProfilePhoto):
|
|
||||||
changed = await self.update_avatar(source, info.photo.photo_big) or changed
|
|
||||||
|
|
||||||
self.is_bot = info.bot
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
async def update_displayname(self, source, info):
|
|
||||||
ignore_source = (not source.is_relaybot
|
|
||||||
and self.displayname_source is not None
|
|
||||||
and self.displayname_source != source.tgid)
|
|
||||||
if ignore_source:
|
|
||||||
return
|
|
||||||
|
|
||||||
displayname = self.get_displayname(info)
|
|
||||||
if displayname != self.displayname:
|
|
||||||
await self.default_mxid_intent.set_display_name(displayname)
|
|
||||||
self.displayname = displayname
|
|
||||||
self.displayname_source = source.tgid
|
|
||||||
return True
|
|
||||||
elif source.is_relaybot or self.displayname_source is None:
|
|
||||||
self.displayname_source = source.tgid
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def update_avatar(self, source, photo):
|
|
||||||
photo_id = f"{photo.volume_id}-{photo.local_id}"
|
|
||||||
if self.photo_id != photo_id:
|
|
||||||
file = await util.transfer_file_to_matrix(self.db, source.client,
|
|
||||||
self.default_mxid_intent, photo)
|
|
||||||
if file:
|
|
||||||
await self.default_mxid_intent.set_avatar(file.mxc)
|
|
||||||
self.photo_id = photo_id
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
# region Getters
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get(cls, tgid, create=True) -> "Optional[Puppet]":
|
|
||||||
try:
|
|
||||||
return cls.cache[tgid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
puppet = DBPuppet.query.get(tgid)
|
|
||||||
if puppet:
|
|
||||||
return cls.from_db(puppet)
|
|
||||||
|
|
||||||
if create:
|
|
||||||
puppet = cls(tgid)
|
|
||||||
cls.db.add(puppet.db_instance)
|
|
||||||
cls.db.commit()
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_mxid(cls, mxid, create=True) -> "Optional[Puppet]":
|
|
||||||
tgid = cls.get_id_from_mxid(mxid)
|
|
||||||
return cls.get(tgid, create) if tgid else None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_custom_mxid(cls, mxid):
|
|
||||||
if not mxid:
|
|
||||||
raise ValueError("Matrix ID can't be empty")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return cls.by_custom_mxid[mxid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
puppet = DBPuppet.query.filter(DBPuppet.custom_mxid == mxid).one_or_none()
|
|
||||||
if puppet:
|
|
||||||
puppet = cls.from_db(puppet)
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_all_with_custom_mxid(cls):
|
|
||||||
return [cls.by_custom_mxid[puppet.mxid]
|
|
||||||
if puppet.custom_mxid in cls.by_custom_mxid
|
|
||||||
else cls.from_db(puppet)
|
|
||||||
for puppet in DBPuppet.query.filter(DBPuppet.custom_mxid is not None).all()]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_id_from_mxid(cls, mxid):
|
|
||||||
match = cls.mxid_regex.match(mxid)
|
|
||||||
if match:
|
|
||||||
return int(match.group(1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_mxid_from_id(cls, tgid):
|
|
||||||
return f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_username(cls, username) -> "Optional[Puppet]":
|
|
||||||
if not username:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for _, puppet in cls.cache.items():
|
|
||||||
if puppet.username and puppet.username.lower() == username.lower():
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
puppet = DBPuppet.query.filter(DBPuppet.username == username).one_or_none()
|
|
||||||
if puppet:
|
|
||||||
return cls.from_db(puppet)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_displayname(cls, displayname) -> "Optional[Puppet]":
|
|
||||||
if not displayname:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for _, puppet in cls.cache.items():
|
|
||||||
if puppet.displayname and puppet.displayname == displayname:
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
puppet = DBPuppet.query.filter(DBPuppet.displayname == displayname).one_or_none()
|
|
||||||
if puppet:
|
|
||||||
return cls.from_db(puppet)
|
|
||||||
|
|
||||||
return None
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
|
|
||||||
def init(context: "Context") -> List[Awaitable[int]]:
|
|
||||||
global config
|
|
||||||
Puppet.az, Puppet.db, config, Puppet.loop, _ = context
|
|
||||||
Puppet.mx = context.mx
|
|
||||||
Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
|
|
||||||
Puppet.hs_domain = config["homeserver"]["domain"]
|
|
||||||
Puppet.mxid_regex = re.compile(
|
|
||||||
f"@{Puppet.username_template.format(userid='(.+)')}:{Puppet.hs_domain}")
|
|
||||||
return [puppet.init_custom_mxid() for puppet in Puppet.get_all_with_custom_mxid()]
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import sqlalchemy as sql
|
|
||||||
from sqlalchemy import orm
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
|
|
||||||
from alchemysession import AlchemySessionContainer
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="mautrix-telegram dbms migration script",
|
|
||||||
prog="python -m mautrix_telegram.scripts.dbms_migrate")
|
|
||||||
parser.add_argument("-f", "--from-url", type=str, required=True, metavar="<url>",
|
|
||||||
help="the old database path")
|
|
||||||
parser.add_argument("-t", "--to-url", type=str, required=True, metavar="<url>",
|
|
||||||
help="the new database path")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def connect(to):
|
|
||||||
import mautrix_telegram.base as base
|
|
||||||
base.Base = declarative_base()
|
|
||||||
from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile,
|
|
||||||
Contact, Puppet, BotChat, TelegramFile)
|
|
||||||
db_engine = sql.create_engine(to)
|
|
||||||
db_factory = orm.sessionmaker(bind=db_engine)
|
|
||||||
db_session = orm.scoped_session(db_factory) # type: orm.Session
|
|
||||||
base.Base.metadata.bind = db_engine
|
|
||||||
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
|
|
||||||
table_base=base.Base, table_prefix="telethon_",
|
|
||||||
manage_tables=False)
|
|
||||||
|
|
||||||
return db_session, {
|
|
||||||
"Version": session_container.Version,
|
|
||||||
"Session": session_container.Session,
|
|
||||||
"Entity": session_container.Entity,
|
|
||||||
"SentFile": session_container.SentFile,
|
|
||||||
"UpdateState": session_container.UpdateState,
|
|
||||||
"Portal": Portal,
|
|
||||||
"Message": Message,
|
|
||||||
"Puppet": Puppet,
|
|
||||||
"User": User,
|
|
||||||
"UserPortal": UserPortal,
|
|
||||||
"RoomState": RoomState,
|
|
||||||
"UserProfile": UserProfile,
|
|
||||||
"Contact": Contact,
|
|
||||||
"BotChat": BotChat,
|
|
||||||
"TelegramFile": TelegramFile,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
session, tables = connect(args.from_url)
|
|
||||||
|
|
||||||
data = {}
|
|
||||||
for name, table in tables.items():
|
|
||||||
data[name] = session.query(table).all()
|
|
||||||
|
|
||||||
session, tables = connect(args.to_url)
|
|
||||||
for name, table in tables.items():
|
|
||||||
for row in data[name]:
|
|
||||||
session.merge(row)
|
|
||||||
session.commit()
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import sqlalchemy as sql
|
|
||||||
from sqlalchemy import orm
|
|
||||||
|
|
||||||
from mautrix_telegram.base import Base
|
|
||||||
from mautrix_telegram.config import Config
|
|
||||||
from mautrix_telegram.db import Portal, Message, Puppet, BotChat
|
|
||||||
from .models import ChatLink, TgUser, MatrixUser, Message as TMMessage, Base as TelematrixBase
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="mautrix-telegram telematrix import script",
|
|
||||||
prog="python -m mautrix_telegram.scripts.telematrix_import")
|
|
||||||
parser.add_argument("-c", "--config", type=str, default="config.yaml",
|
|
||||||
metavar="<path>", help="the path to your mautrix-telegram config file")
|
|
||||||
parser.add_argument("-b", "--bot-id", type=int, required=True,
|
|
||||||
metavar="<id>", help="the telegram user ID of your relay bot")
|
|
||||||
parser.add_argument("-t", "--telematrix-database", type=str, default="sqlite:///database.db",
|
|
||||||
metavar="<url>", help="your telematrix database URL")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
config = Config(args.config, None, None)
|
|
||||||
config.load()
|
|
||||||
|
|
||||||
mxtg_db_engine = sql.create_engine(config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
|
|
||||||
mxtg = orm.sessionmaker(bind=mxtg_db_engine)()
|
|
||||||
Base.metadata.bind = mxtg_db_engine
|
|
||||||
|
|
||||||
telematrix_db_engine = sql.create_engine(args.telematrix_database)
|
|
||||||
telematrix = orm.sessionmaker(bind=telematrix_db_engine)()
|
|
||||||
TelematrixBase.metadata.bind = telematrix_db_engine
|
|
||||||
|
|
||||||
chat_links = telematrix.query(ChatLink).all()
|
|
||||||
tg_users = telematrix.query(TgUser).all()
|
|
||||||
mx_users = telematrix.query(MatrixUser).all()
|
|
||||||
messages = telematrix.query(TMMessage).all()
|
|
||||||
|
|
||||||
telematrix.close()
|
|
||||||
telematrix_db_engine.dispose()
|
|
||||||
|
|
||||||
portals = {}
|
|
||||||
chats = {}
|
|
||||||
messages = {}
|
|
||||||
puppets = {}
|
|
||||||
|
|
||||||
for chat_link in chat_links:
|
|
||||||
if type(chat_link.tg_room) is str:
|
|
||||||
print("Expected tg_room to be a number, got a string. Ignoring %s" % chat_link.tg_room)
|
|
||||||
continue
|
|
||||||
if chat_link.tg_room >= 0:
|
|
||||||
print("Unexpected unprefixed telegram chat ID: %s, ignoring..." % chat_link.tg_room)
|
|
||||||
continue
|
|
||||||
tgid = str(chat_link.tg_room)
|
|
||||||
if tgid.startswith("-100"):
|
|
||||||
tgid = int(tgid[4:])
|
|
||||||
peer_type = "channel"
|
|
||||||
megagroup = True
|
|
||||||
else:
|
|
||||||
tgid = -chat_link.tg_room
|
|
||||||
peer_type = "chat"
|
|
||||||
megagroup = False
|
|
||||||
|
|
||||||
portal = Portal(tgid=tgid, tg_receiver=tgid, peer_type=peer_type, megagroup=megagroup,
|
|
||||||
mxid=chat_link.matrix_room)
|
|
||||||
bot_chat = BotChat(id=tgid, type=peer_type)
|
|
||||||
portals[chat_link.tg_room] = portal
|
|
||||||
chats[tgid] = bot_chat
|
|
||||||
|
|
||||||
for tm_msg in messages:
|
|
||||||
try:
|
|
||||||
portal = portals[tm_msg.tg_group_id]
|
|
||||||
except KeyError:
|
|
||||||
print("Found message entry %d in unlinked chat %d, ignoring..." % (tm_msg.tg_message_id, tm_msg.tg_group_id))
|
|
||||||
continue
|
|
||||||
tg_space = portal.tgid if portal.peer_type == "channel" else args.bot_id
|
|
||||||
message = Message(mxid=tm_msg.matrix_event_id, mx_room=tm_msg.matrix_room_id,
|
|
||||||
tgid=tm_msg.tg_message_id, tg_space=tg_space)
|
|
||||||
messages[tm_msg.matrix_event_id] = message
|
|
||||||
|
|
||||||
for user in tg_users:
|
|
||||||
puppets[user.tg_id] = Puppet(id=user.tg_id, displayname=user.name, displayname_source=args.bot_id)
|
|
||||||
|
|
||||||
for k, v in portals.items():
|
|
||||||
mxtg.add(v)
|
|
||||||
for k, v in chats.items():
|
|
||||||
mxtg.add(v)
|
|
||||||
for k, v in messages.items():
|
|
||||||
mxtg.add(v)
|
|
||||||
for k, v in puppets.items():
|
|
||||||
mxtg.add(v)
|
|
||||||
|
|
||||||
mxtg.commit()
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class ChatLink(Base):
|
|
||||||
__tablename__ = 'chat_link'
|
|
||||||
|
|
||||||
id = sa.Column(sa.Integer, primary_key=True)
|
|
||||||
matrix_room = sa.Column(sa.String)
|
|
||||||
tg_room = sa.Column(sa.BigInteger)
|
|
||||||
active = sa.Column(sa.Boolean)
|
|
||||||
|
|
||||||
|
|
||||||
class TgUser(Base):
|
|
||||||
__tablename__ = 'tg_user'
|
|
||||||
|
|
||||||
id = sa.Column(sa.Integer, primary_key=True)
|
|
||||||
tg_id = sa.Column(sa.BigInteger)
|
|
||||||
name = sa.Column(sa.String)
|
|
||||||
profile_pic_id = sa.Column(sa.String, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixUser(Base):
|
|
||||||
__tablename__ = 'matrix_user'
|
|
||||||
|
|
||||||
id = sa.Column(sa.Integer, primary_key=True)
|
|
||||||
matrix_id = sa.Column(sa.String)
|
|
||||||
name = sa.Column(sa.String)
|
|
||||||
|
|
||||||
|
|
||||||
class Message(Base):
|
|
||||||
"""Describes a message in a room bridged between Telegram and Matrix"""
|
|
||||||
__tablename__ = "message"
|
|
||||||
|
|
||||||
id = sa.Column(sa.Integer, primary_key=True)
|
|
||||||
tg_group_id = sa.Column(sa.BigInteger)
|
|
||||||
tg_message_id = sa.Column(sa.BigInteger)
|
|
||||||
|
|
||||||
matrix_room_id = sa.Column(sa.String)
|
|
||||||
matrix_event_id = sa.Column(sa.String)
|
|
||||||
|
|
||||||
displayname = sa.Column(sa.String)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Dict, Tuple
|
|
||||||
|
|
||||||
from sqlalchemy import orm
|
|
||||||
|
|
||||||
from mautrix_appservice import StateStore
|
|
||||||
|
|
||||||
from . import puppet as pu
|
|
||||||
from .db import RoomState, UserProfile
|
|
||||||
|
|
||||||
|
|
||||||
class SQLStateStore(StateStore):
|
|
||||||
def __init__(self, db):
|
|
||||||
super().__init__()
|
|
||||||
self.db = db # type: orm.Session
|
|
||||||
self.profile_cache = {} # type: Dict[Tuple[str, str], UserProfile]
|
|
||||||
self.room_state_cache = {} # type: Dict[str, RoomState]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def is_registered(user: str) -> bool:
|
|
||||||
puppet = pu.Puppet.get_by_mxid(user)
|
|
||||||
return puppet.is_registered if puppet else False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def registered(user: str):
|
|
||||||
puppet = pu.Puppet.get_by_mxid(user)
|
|
||||||
if puppet:
|
|
||||||
puppet.is_registered = True
|
|
||||||
puppet.save()
|
|
||||||
|
|
||||||
def update_state(self, event: dict):
|
|
||||||
event_type = event["type"]
|
|
||||||
if event_type == "m.room.power_levels":
|
|
||||||
self.set_power_levels(event["room_id"], event["content"])
|
|
||||||
elif event_type == "m.room.member":
|
|
||||||
self.set_member(event["room_id"], event["state_key"], event["content"])
|
|
||||||
|
|
||||||
def _get_user_profile(self, room_id: str, user_id: str, create: bool = True) -> UserProfile:
|
|
||||||
key = (room_id, user_id)
|
|
||||||
try:
|
|
||||||
return self.profile_cache[key]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
profile = UserProfile.query.get(key)
|
|
||||||
if profile:
|
|
||||||
self.profile_cache[key] = profile
|
|
||||||
elif create:
|
|
||||||
profile = UserProfile(room_id=room_id, user_id=user_id)
|
|
||||||
self.db.add(profile)
|
|
||||||
self.db.commit()
|
|
||||||
self.profile_cache[key] = profile
|
|
||||||
return profile
|
|
||||||
|
|
||||||
def get_member(self, room: str, user: str) -> dict:
|
|
||||||
return self._get_user_profile(room, user).dict()
|
|
||||||
|
|
||||||
def set_member(self, room: str, user: str, member: dict):
|
|
||||||
profile = self._get_user_profile(room, user)
|
|
||||||
profile.membership = member.get("membership", profile.membership or "leave")
|
|
||||||
profile.displayname = member.get("displayname", profile.displayname)
|
|
||||||
profile.avatar_url = member.get("avatar_url", profile.avatar_url)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def set_membership(self, room: str, user: str, membership: str):
|
|
||||||
self.set_member(room, user, {
|
|
||||||
"membership": membership,
|
|
||||||
})
|
|
||||||
|
|
||||||
def _get_room_state(self, room_id: str, create: bool = True) -> RoomState:
|
|
||||||
try:
|
|
||||||
return self.room_state_cache[room_id]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
room = RoomState.query.get(room_id)
|
|
||||||
if room:
|
|
||||||
self.room_state_cache[room_id] = room
|
|
||||||
elif create:
|
|
||||||
room = RoomState(room_id=room_id)
|
|
||||||
self.room_state_cache[room_id] = room
|
|
||||||
return room
|
|
||||||
|
|
||||||
def has_power_levels(self, room: str) -> bool:
|
|
||||||
return self._get_room_state(room).has_power_levels
|
|
||||||
|
|
||||||
def get_power_levels(self, room: str) -> dict:
|
|
||||||
return self._get_room_state(room).power_levels
|
|
||||||
|
|
||||||
def set_power_level(self, room: str, user: str, level: int):
|
|
||||||
room_state = self._get_room_state(room)
|
|
||||||
power_levels = room_state.power_levels
|
|
||||||
if not power_levels:
|
|
||||||
power_levels = {
|
|
||||||
"users": {},
|
|
||||||
"events": {},
|
|
||||||
}
|
|
||||||
power_levels[room]["users"][user] = level
|
|
||||||
room_state.power_levels = power_levels
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def set_power_levels(self, room: str, content: dict):
|
|
||||||
state = self._get_room_state(room)
|
|
||||||
state.power_levels = content
|
|
||||||
self.db.commit()
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from telethon import TelegramClient, utils
|
|
||||||
from telethon.tl.functions.messages import SendMediaRequest
|
|
||||||
from telethon.tl.types import *
|
|
||||||
from telethon.tl import custom
|
|
||||||
|
|
||||||
|
|
||||||
class MautrixTelegramClient(TelegramClient):
|
|
||||||
async def upload_file_direct(self, file: bytes, mime_type: str = None,
|
|
||||||
attributes: List[TypeDocumentAttribute] = None,
|
|
||||||
file_name: str = None
|
|
||||||
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]:
|
|
||||||
file_handle = await super().upload_file(file, file_name=file_name, use_cache=False)
|
|
||||||
|
|
||||||
if mime_type == "image/png" or mime_type == "image/jpeg":
|
|
||||||
return InputMediaUploadedPhoto(file_handle)
|
|
||||||
else:
|
|
||||||
attributes = attributes or []
|
|
||||||
attr_dict = {type(attr): attr for attr in attributes}
|
|
||||||
|
|
||||||
return InputMediaUploadedDocument(
|
|
||||||
file=file_handle,
|
|
||||||
mime_type=mime_type or "application/octet-stream",
|
|
||||||
attributes=list(attr_dict.values()))
|
|
||||||
|
|
||||||
async def send_media(self, entity: Union[TypeInputPeer, TypePeer],
|
|
||||||
media: Union[TypeInputMedia, TypeMessageMedia],
|
|
||||||
caption: str = None, entities: List[TypeMessageEntity] = None,
|
|
||||||
reply_to: int = None) -> Optional[custom.Message]:
|
|
||||||
entity = await self.get_input_entity(entity)
|
|
||||||
reply_to = utils.get_message_id(reply_to)
|
|
||||||
request = SendMediaRequest(entity, media, message=caption or "", entities=entities or [],
|
|
||||||
reply_to_msg_id=reply_to)
|
|
||||||
return self._get_response_message(request, await self(request), entity)
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Dict, Awaitable, Optional, Match, Tuple, TYPE_CHECKING
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import re
|
|
||||||
|
|
||||||
from telethon.tl.types import *
|
|
||||||
from telethon.tl.types import User as TLUser
|
|
||||||
from telethon.tl.types.contacts import ContactsNotModified
|
|
||||||
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
|
|
||||||
from telethon.tl.functions.account import UpdateStatusRequest
|
|
||||||
from mautrix_appservice import MatrixRequestError
|
|
||||||
|
|
||||||
from .db import User as DBUser, Contact as DBContact, Portal as DBPortal
|
|
||||||
from .abstract_user import AbstractUser
|
|
||||||
from . import portal as po, puppet as pu
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .config import Config
|
|
||||||
from .context import Context
|
|
||||||
|
|
||||||
config = None # type: Config
|
|
||||||
|
|
||||||
SearchResults = List[Tuple["pu.Puppet", int]]
|
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
|
||||||
log = logging.getLogger("mau.user") # type: logging.Logger
|
|
||||||
by_mxid = {} # type: Dict[str, User]
|
|
||||||
by_tgid = {} # type: Dict[int, User]
|
|
||||||
|
|
||||||
def __init__(self, mxid: str, tgid: Optional[int] = None, username: Optional[str] = None,
|
|
||||||
db_contacts: Optional[List[DBContact]] = None, saved_contacts: int = 0,
|
|
||||||
is_bot: bool = False, db_portals: Optional[List[DBPortal]] = None,
|
|
||||||
db_instance: Optional[DBUser] = None):
|
|
||||||
super().__init__()
|
|
||||||
self.mxid = mxid # type: str
|
|
||||||
self.tgid = tgid # type: int
|
|
||||||
self.is_bot = is_bot # type: bool
|
|
||||||
self.username = username # type: str
|
|
||||||
self.contacts = [] # type: List[pu.Puppet]
|
|
||||||
self.saved_contacts = saved_contacts # type: int
|
|
||||||
self.db_contacts = db_contacts # type: List[DBContact]
|
|
||||||
self.portals = {} # type: Dict[Tuple[int, int], po.Portal]
|
|
||||||
self.db_portals = db_portals # type: List[DBPortal]
|
|
||||||
self._db_instance = db_instance # type: DBUser
|
|
||||||
|
|
||||||
self.command_status = None # type: dict
|
|
||||||
|
|
||||||
(self.relaybot_whitelisted,
|
|
||||||
self.whitelisted,
|
|
||||||
self.puppet_whitelisted,
|
|
||||||
self.matrix_puppet_whitelisted,
|
|
||||||
self.is_admin,
|
|
||||||
self.permissions) = config.get_permissions(self.mxid)
|
|
||||||
|
|
||||||
self.by_mxid[mxid] = self
|
|
||||||
if tgid:
|
|
||||||
self.by_tgid[tgid] = self
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self.mxid
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mxid_localpart(self) -> str:
|
|
||||||
match = re.compile("@(.+):(.+)").match(self.mxid) # type: Match
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
# TODO replace with proper displayname getting everywhere
|
|
||||||
@property
|
|
||||||
def displayname(self) -> str:
|
|
||||||
return self.mxid_localpart
|
|
||||||
|
|
||||||
@property
|
|
||||||
def db_contacts(self) -> List[DBContact]:
|
|
||||||
return [self.db.merge(DBContact(user=self.tgid, contact=puppet.id))
|
|
||||||
for puppet in self.contacts]
|
|
||||||
|
|
||||||
@db_contacts.setter
|
|
||||||
def db_contacts(self, contacts: List[DBContact]):
|
|
||||||
self.contacts = [pu.Puppet.get(entry.contact) for entry in contacts] if contacts else []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def db_portals(self) -> List[DBPortal]:
|
|
||||||
return [portal.db_instance for portal in self.portals.values() if not portal.deleted]
|
|
||||||
|
|
||||||
@db_portals.setter
|
|
||||||
def db_portals(self, portals: List[DBPortal]):
|
|
||||||
self.portals = {(portal.tgid, portal.tg_receiver):
|
|
||||||
po.Portal.get_by_tgid(portal.tgid, portal.tg_receiver)
|
|
||||||
for portal in portals} if portals else {}
|
|
||||||
|
|
||||||
# region Database conversion
|
|
||||||
|
|
||||||
@property
|
|
||||||
def db_instance(self) -> DBUser:
|
|
||||||
if not self._db_instance:
|
|
||||||
self._db_instance = self.new_db_instance()
|
|
||||||
return self._db_instance
|
|
||||||
|
|
||||||
def new_db_instance(self) -> DBUser:
|
|
||||||
return DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
|
|
||||||
contacts=self.db_contacts, saved_contacts=self.saved_contacts or 0,
|
|
||||||
portals=self.db_portals)
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
self.db_instance.tgid = self.tgid
|
|
||||||
self.db_instance.username = self.username
|
|
||||||
self.db_instance.contacts = self.db_contacts
|
|
||||||
self.db_instance.saved_contacts = self.saved_contacts or 0
|
|
||||||
self.db_instance.portals = self.db_portals
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def delete(self):
|
|
||||||
try:
|
|
||||||
del self.by_mxid[self.mxid]
|
|
||||||
del self.by_tgid[self.tgid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
if self._db_instance:
|
|
||||||
self.db.delete(self._db_instance)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_db(cls, db_user: DBUser) -> "User":
|
|
||||||
return User(db_user.mxid, db_user.tgid, db_user.tg_username, db_user.contacts,
|
|
||||||
False, db_user.saved_contacts, db_user.portals, db_instance=db_user)
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
# region Telegram connection management
|
|
||||||
|
|
||||||
async def start(self, delete_unless_authenticated: bool = False) -> "User":
|
|
||||||
await super().start()
|
|
||||||
if await self.is_logged_in():
|
|
||||||
self.log.debug(f"Ensuring post_login() for {self.name}")
|
|
||||||
asyncio.ensure_future(self.post_login(), loop=self.loop)
|
|
||||||
elif delete_unless_authenticated:
|
|
||||||
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
|
|
||||||
await self.client.disconnect()
|
|
||||||
self.client.session.delete()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def post_login(self, info: TLUser = None):
|
|
||||||
try:
|
|
||||||
await self.update_info(info)
|
|
||||||
if not self.is_bot:
|
|
||||||
await self.sync_dialogs()
|
|
||||||
await self.sync_contacts()
|
|
||||||
if config["bridge.catch_up"]:
|
|
||||||
await self.client.catch_up()
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Failed to run post-login functions for %s", self.mxid)
|
|
||||||
|
|
||||||
async def update(self, update: TypeUpdate):
|
|
||||||
if not self.is_bot:
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
|
|
||||||
message = update.message
|
|
||||||
if isinstance(message.to_id, PeerUser) and not message.out:
|
|
||||||
portal = po.Portal.get_by_tgid(message.from_id, peer_type="user",
|
|
||||||
tg_receiver=self.tgid)
|
|
||||||
else:
|
|
||||||
portal = po.Portal.get_by_entity(message.to_id, receiver_id=self.tgid)
|
|
||||||
elif isinstance(update, UpdateShortChatMessage):
|
|
||||||
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
|
|
||||||
elif isinstance(update, UpdateShortMessage):
|
|
||||||
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.register_portal(portal)
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
# region Telegram actions that need custom methods
|
|
||||||
|
|
||||||
def ensure_started(self, even_if_no_session: bool = False) -> "Awaitable[User]":
|
|
||||||
return super().ensure_started(even_if_no_session)
|
|
||||||
|
|
||||||
def set_presence(self, online: bool = True):
|
|
||||||
if self.is_bot:
|
|
||||||
return
|
|
||||||
return self.client(UpdateStatusRequest(offline=not online))
|
|
||||||
|
|
||||||
async def update_info(self, info: TLUser = None):
|
|
||||||
info = info or await self.client.get_me()
|
|
||||||
changed = False
|
|
||||||
if self.is_bot != info.bot:
|
|
||||||
self.is_bot = info.bot
|
|
||||||
changed = True
|
|
||||||
if self.username != info.username:
|
|
||||||
self.username = info.username
|
|
||||||
changed = True
|
|
||||||
if self.tgid != info.id:
|
|
||||||
self.tgid = info.id
|
|
||||||
self.by_tgid[self.tgid] = self
|
|
||||||
if changed:
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
async def log_out(self):
|
|
||||||
puppet = pu.Puppet.get(self.tgid)
|
|
||||||
if puppet.is_real_user:
|
|
||||||
await puppet.switch_mxid(None, None)
|
|
||||||
for _, portal in self.portals.items():
|
|
||||||
if not portal.mxid or portal.has_bot:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await portal.main_intent.kick(portal.mxid, self.mxid, "Logged out of Telegram.")
|
|
||||||
except MatrixRequestError:
|
|
||||||
pass
|
|
||||||
self.portals = {}
|
|
||||||
self.contacts = []
|
|
||||||
self.save()
|
|
||||||
if self.tgid:
|
|
||||||
try:
|
|
||||||
del self.by_tgid[self.tgid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
self.tgid = None
|
|
||||||
self.save()
|
|
||||||
ok = await self.client.log_out()
|
|
||||||
if not ok:
|
|
||||||
return False
|
|
||||||
self.delete()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
|
|
||||||
) -> SearchResults:
|
|
||||||
results = [] # type: SearchResults
|
|
||||||
for contact in self.contacts:
|
|
||||||
similarity = contact.similarity(query)
|
|
||||||
if similarity >= min_similarity:
|
|
||||||
results.append((contact, similarity))
|
|
||||||
results.sort(key=lambda tup: tup[1], reverse=True)
|
|
||||||
return results[0:max_results]
|
|
||||||
|
|
||||||
async def _search_remote(self, query: str, max_results: int = 5) -> SearchResults:
|
|
||||||
if len(query) < 5:
|
|
||||||
return []
|
|
||||||
server_results = await self.client(SearchRequest(q=query, limit=max_results))
|
|
||||||
results = [] # type: SearchResults
|
|
||||||
for user in server_results.users:
|
|
||||||
puppet = pu.Puppet.get(user.id)
|
|
||||||
await puppet.update_info(self, user)
|
|
||||||
results.append((puppet, puppet.similarity(query)))
|
|
||||||
results.sort(key=lambda tup: tup[1], reverse=True)
|
|
||||||
return results[0:max_results]
|
|
||||||
|
|
||||||
async def search(self, query: str, force_remote: bool = False) -> Tuple[SearchResults, bool]:
|
|
||||||
if force_remote:
|
|
||||||
return await self._search_remote(query), True
|
|
||||||
|
|
||||||
results = self._search_local(query)
|
|
||||||
if results:
|
|
||||||
return results, False
|
|
||||||
|
|
||||||
return await self._search_remote(query), True
|
|
||||||
|
|
||||||
async def sync_dialogs(self, synchronous_create: bool = False):
|
|
||||||
creators = []
|
|
||||||
for entity in await self.get_dialogs(limit=30):
|
|
||||||
portal = po.Portal.get_by_entity(entity)
|
|
||||||
self.portals[portal.tgid_full] = portal
|
|
||||||
creators.append(
|
|
||||||
portal.create_matrix_room(self, entity, invites=[self.mxid],
|
|
||||||
synchronous=synchronous_create))
|
|
||||||
self.save()
|
|
||||||
await asyncio.gather(*creators, loop=self.loop)
|
|
||||||
|
|
||||||
def register_portal(self, portal: po.Portal):
|
|
||||||
try:
|
|
||||||
if self.portals[portal.tgid_full] == portal:
|
|
||||||
return
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
self.portals[portal.tgid_full] = portal
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def unregister_portal(self, portal: po.Portal):
|
|
||||||
try:
|
|
||||||
del self.portals[portal.tgid_full]
|
|
||||||
self.save()
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def needs_relaybot(self, portal: po.Portal) -> bool:
|
|
||||||
return not await self.is_logged_in() or (
|
|
||||||
self.is_bot and portal.tgid_full not in self.portals)
|
|
||||||
|
|
||||||
def _hash_contacts(self) -> int:
|
|
||||||
acc = 0
|
|
||||||
for id in sorted([self.saved_contacts] + [contact.id for contact in self.contacts]):
|
|
||||||
acc = (acc * 20261 + id) & 0xffffffff
|
|
||||||
return acc & 0x7fffffff
|
|
||||||
|
|
||||||
async def sync_contacts(self):
|
|
||||||
response = await self.client(GetContactsRequest(hash=self._hash_contacts()))
|
|
||||||
if isinstance(response, ContactsNotModified):
|
|
||||||
return
|
|
||||||
self.log.debug("Updating contacts...")
|
|
||||||
self.contacts = []
|
|
||||||
self.saved_contacts = response.saved_count
|
|
||||||
for user in response.users:
|
|
||||||
puppet = pu.Puppet.get(user.id)
|
|
||||||
await puppet.update_info(self, user)
|
|
||||||
self.contacts.append(puppet)
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
# region Class instance lookup
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_mxid(cls, mxid: str, create: bool=True) -> "Optional[User]":
|
|
||||||
if not mxid:
|
|
||||||
raise ValueError("Matrix ID can't be empty")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return cls.by_mxid[mxid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
user = DBUser.query.get(mxid)
|
|
||||||
if user:
|
|
||||||
user = cls.from_db(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
if create:
|
|
||||||
user = cls(mxid)
|
|
||||||
cls.db.add(user.db_instance)
|
|
||||||
cls.db.commit()
|
|
||||||
return user
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_tgid(cls, tgid: int) -> "Optional[User]":
|
|
||||||
try:
|
|
||||||
return cls.by_tgid[tgid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
user = DBUser.query.filter(DBUser.tgid == tgid).one_or_none()
|
|
||||||
if user:
|
|
||||||
user = cls.from_db(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_by_username(cls, username: str) -> "Optional[User]":
|
|
||||||
if not username:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for _, user in cls.by_tgid.items():
|
|
||||||
if user.username and user.username.lower() == username.lower():
|
|
||||||
return user
|
|
||||||
|
|
||||||
puppet = DBUser.query.filter(DBUser.tg_username == username).one_or_none()
|
|
||||||
if puppet:
|
|
||||||
return cls.from_db(puppet)
|
|
||||||
|
|
||||||
return None
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
|
|
||||||
def init(context: "Context") -> List[Awaitable[User]]:
|
|
||||||
global config
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
users = [User.from_db(user) for user in DBUser.query.all()]
|
|
||||||
return [user.ensure_started() for user in users]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .file_transfer import transfer_file_to_matrix, convert_image
|
|
||||||
from .format_duration import format_duration
|
|
||||||
from .signed_token import sign_token, verify_token
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, Tuple, Union, Dict
|
|
||||||
from io import BytesIO
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import magic
|
|
||||||
from sqlalchemy import orm
|
|
||||||
from sqlalchemy.exc import IntegrityError, InvalidRequestError
|
|
||||||
from sqlalchemy.orm.exc import FlushError
|
|
||||||
|
|
||||||
from telethon.tl.types import (Document, FileLocation, InputFileLocation,
|
|
||||||
InputDocumentFileLocation, PhotoSize, PhotoCachedSize)
|
|
||||||
from telethon.errors import *
|
|
||||||
from mautrix_appservice import IntentAPI
|
|
||||||
|
|
||||||
from ..tgclient import MautrixTelegramClient
|
|
||||||
from ..db import TelegramFile as DBTelegramFile
|
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
except ImportError:
|
|
||||||
Image = None
|
|
||||||
try:
|
|
||||||
from moviepy.editor import VideoFileClip
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import os
|
|
||||||
import mimetypes
|
|
||||||
except ImportError:
|
|
||||||
VideoFileClip = random = string = os = mimetypes = None
|
|
||||||
|
|
||||||
log = logging.getLogger("mau.util") # type: logging.Logger
|
|
||||||
|
|
||||||
TypeLocation = Union[Document, InputDocumentFileLocation, FileLocation, InputFileLocation]
|
|
||||||
|
|
||||||
|
|
||||||
def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str = "png",
|
|
||||||
thumbnail_to: Optional[Tuple[int, int]] = None
|
|
||||||
) -> Tuple[str, bytes, Optional[int], Optional[int]]:
|
|
||||||
if not Image:
|
|
||||||
return source_mime, file, None, None
|
|
||||||
try:
|
|
||||||
image = Image.open(BytesIO(file)).convert("RGBA") # type: Image.Image
|
|
||||||
if thumbnail_to:
|
|
||||||
image.thumbnail(thumbnail_to, Image.ANTIALIAS)
|
|
||||||
new_file = BytesIO()
|
|
||||||
image.save(new_file, target_type)
|
|
||||||
w, h = image.size
|
|
||||||
return f"image/{target_type}", new_file.getvalue(), w, h
|
|
||||||
except Exception:
|
|
||||||
log.exception(f"Failed to convert {source_mime} to {target_type}")
|
|
||||||
return source_mime, file, None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _temp_file_name(ext: str) -> str:
|
|
||||||
return ("/tmp/mxtg-video-"
|
|
||||||
+ "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10))
|
|
||||||
+ ext)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_video_thumbnail(data: bytes, video_ext: str = "mp4", frame_ext: str = "png",
|
|
||||||
max_size: Tuple[int, int] = (1024, 720)) -> Tuple[bytes, int, int]:
|
|
||||||
# We don't have any way to read the video from memory, so save it to disk.
|
|
||||||
temp_file = _temp_file_name(video_ext)
|
|
||||||
with open(temp_file, "wb") as file:
|
|
||||||
file.write(data)
|
|
||||||
|
|
||||||
# Read temp file and get frame
|
|
||||||
clip = VideoFileClip(temp_file)
|
|
||||||
frame = clip.get_frame(0)
|
|
||||||
|
|
||||||
# Convert to png and save to BytesIO
|
|
||||||
image = Image.fromarray(frame).convert("RGBA")
|
|
||||||
thumbnail_file = BytesIO()
|
|
||||||
if max_size:
|
|
||||||
image.thumbnail(max_size, Image.ANTIALIAS)
|
|
||||||
image.save(thumbnail_file, frame_ext)
|
|
||||||
|
|
||||||
os.remove(temp_file)
|
|
||||||
|
|
||||||
w, h = image.size
|
|
||||||
return thumbnail_file.getvalue(), w, h
|
|
||||||
|
|
||||||
|
|
||||||
def _location_to_id(location: TypeLocation) -> str:
|
|
||||||
if isinstance(location, (Document, InputDocumentFileLocation)):
|
|
||||||
return f"{location.id}-{location.version}"
|
|
||||||
elif isinstance(location, (FileLocation, InputFileLocation)):
|
|
||||||
return f"{location.volume_id}-{location.local_id}"
|
|
||||||
|
|
||||||
|
|
||||||
async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
|
|
||||||
thumbnail_loc: TypeLocation, video: bytes,
|
|
||||||
mime: str) -> Optional[DBTelegramFile]:
|
|
||||||
if not Image or not VideoFileClip:
|
|
||||||
return None
|
|
||||||
|
|
||||||
loc_id = _location_to_id(thumbnail_loc)
|
|
||||||
if not loc_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
video_ext = mimetypes.guess_extension(mime)
|
|
||||||
if VideoFileClip and video_ext:
|
|
||||||
try:
|
|
||||||
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
mime_type = "image/png"
|
|
||||||
else:
|
|
||||||
file = await client.download_file(thumbnail_loc)
|
|
||||||
width, height = None, None
|
|
||||||
mime_type = magic.from_buffer(file, mime=True)
|
|
||||||
|
|
||||||
content_uri = await intent.upload_file(file, mime_type)
|
|
||||||
|
|
||||||
return DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
|
|
||||||
was_converted=False, timestamp=int(time.time()), size=len(file),
|
|
||||||
width=width, height=height)
|
|
||||||
|
|
||||||
|
|
||||||
transfer_locks = {} # type: Dict[str, asyncio.Lock]
|
|
||||||
|
|
||||||
|
|
||||||
async def transfer_file_to_matrix(db: orm.Session, client: MautrixTelegramClient, intent: IntentAPI,
|
|
||||||
location: TypeLocation, thumbnail: Optional[TypeLocation] = None,
|
|
||||||
is_sticker: bool = False) -> Optional[DBTelegramFile]:
|
|
||||||
location_id = _location_to_id(location)
|
|
||||||
if not location_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
db_file = DBTelegramFile.query.get(location_id)
|
|
||||||
if db_file:
|
|
||||||
return db_file
|
|
||||||
|
|
||||||
try:
|
|
||||||
lock = transfer_locks[location_id]
|
|
||||||
except KeyError:
|
|
||||||
lock = asyncio.Lock()
|
|
||||||
transfer_locks[location_id] = lock
|
|
||||||
async with lock:
|
|
||||||
return await _unlocked_transfer_file_to_matrix(db, client, intent, location_id, location,
|
|
||||||
thumbnail, is_sticker)
|
|
||||||
|
|
||||||
|
|
||||||
async def _unlocked_transfer_file_to_matrix(db: orm.Session, client: MautrixTelegramClient,
|
|
||||||
intent: IntentAPI, loc_id: str, location: TypeLocation,
|
|
||||||
thumbnail: Optional[TypeLocation],
|
|
||||||
is_sticker: bool) -> Optional[DBTelegramFile]:
|
|
||||||
db_file = DBTelegramFile.query.get(loc_id)
|
|
||||||
if db_file:
|
|
||||||
return db_file
|
|
||||||
|
|
||||||
try:
|
|
||||||
file = await client.download_file(location)
|
|
||||||
except LocationInvalidError:
|
|
||||||
return None
|
|
||||||
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
|
|
||||||
log.exception(f"{e.__class__.__name__} while downloading a file.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
width, height = None, None
|
|
||||||
mime_type = magic.from_buffer(file, mime=True)
|
|
||||||
|
|
||||||
image_converted = False
|
|
||||||
if mime_type == "image/webp":
|
|
||||||
new_mime_type, file, width, height = convert_image(
|
|
||||||
file, source_mime="image/webp", target_type="png",
|
|
||||||
thumbnail_to=(256, 256) if is_sticker else None)
|
|
||||||
image_converted = new_mime_type != mime_type
|
|
||||||
mime_type = new_mime_type
|
|
||||||
thumbnail = None
|
|
||||||
|
|
||||||
content_uri = await intent.upload_file(file, mime_type)
|
|
||||||
|
|
||||||
db_file = DBTelegramFile(id=loc_id, mxc=content_uri,
|
|
||||||
mime_type=mime_type, was_converted=image_converted,
|
|
||||||
timestamp=int(time.time()), size=len(file),
|
|
||||||
width=width, height=height)
|
|
||||||
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
|
|
||||||
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
|
||||||
thumbnail = thumbnail.location
|
|
||||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(client, intent, thumbnail, file,
|
|
||||||
mime_type)
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.add(db_file)
|
|
||||||
db.commit()
|
|
||||||
except FlushError as e:
|
|
||||||
log.exception(f"{e.__class__.__name__} while saving transferred file data. "
|
|
||||||
"This was probably caused by two simultaneous transfers of the same file, "
|
|
||||||
"and should not cause any problems.")
|
|
||||||
except (IntegrityError, InvalidRequestError) as e:
|
|
||||||
db.rollback()
|
|
||||||
log.exception(f"{e.__class__.__name__} while saving transferred file data. "
|
|
||||||
"This was probably caused by two simultaneous transfers of the same file, "
|
|
||||||
"and should not cause any problems.")
|
|
||||||
|
|
||||||
return db_file
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds: int) -> str:
|
|
||||||
def pluralize(count, singular):
|
|
||||||
return singular if count == 1 else singular + "s"
|
|
||||||
|
|
||||||
def include(count, word):
|
|
||||||
return f"{count} {pluralize(count, word)}" if count > 0 else ""
|
|
||||||
|
|
||||||
minutes, seconds = divmod(seconds, 60)
|
|
||||||
hours, minutes = divmod(minutes, 60)
|
|
||||||
days, hours = divmod(hours, 24)
|
|
||||||
parts = [a for a in [
|
|
||||||
include(days, "day"),
|
|
||||||
include(hours, "hour"),
|
|
||||||
include(minutes, "minute"),
|
|
||||||
include(seconds, "second")] if a]
|
|
||||||
if len(parts) > 2:
|
|
||||||
return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
|
|
||||||
return " and ".join(parts)
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
|
||||||
def _get_checksum(key: str, payload: bytes) -> str:
|
|
||||||
hasher = hashlib.sha256()
|
|
||||||
hasher.update(payload)
|
|
||||||
hasher.update(key.encode("utf-8"))
|
|
||||||
checksum = hasher.hexdigest()
|
|
||||||
return checksum
|
|
||||||
|
|
||||||
|
|
||||||
def sign_token(key: str, payload: dict) -> str:
|
|
||||||
payload = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
|
||||||
checksum = _get_checksum(key, payload)
|
|
||||||
return f"{checksum}:{payload.decode('utf-8')}"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_token(key: str, data: str) -> Optional[dict]:
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
checksum, payload = data.split(":", 1)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if checksum != _get_checksum(key, payload.encode("utf-8")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
payload = base64.urlsafe_b64decode(payload).decode("utf-8")
|
|
||||||
try:
|
|
||||||
return json.loads(payload)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
from .provisioning import ProvisioningAPI
|
|
||||||
from .public import PublicBridgeWebsite
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
from .auth_api import AuthAPI
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from abc import abstractmethod
|
|
||||||
import abc
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from telethon.errors import *
|
|
||||||
|
|
||||||
from ...commands.auth import enter_password
|
|
||||||
from ...util import format_duration
|
|
||||||
from ...puppet import Puppet
|
|
||||||
from ...user import User
|
|
||||||
|
|
||||||
|
|
||||||
class AuthAPI(abc.ABC):
|
|
||||||
log = logging.getLogger("mau.web.auth")
|
|
||||||
|
|
||||||
def __init__(self, loop):
|
|
||||||
self.loop = loop # type: asyncio.AbstractEventLoop
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_login_response(self, status=200, state="", username="", mxid="", message="", error="",
|
|
||||||
errcode=""):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_mx_login_response(self, status=200, state="", username="", mxid="", message="",
|
|
||||||
error="", errcode=""):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
async def post_matrix_token(self, user: User, token):
|
|
||||||
puppet = Puppet.get(user.tgid)
|
|
||||||
if puppet.is_real_user:
|
|
||||||
return self.get_mx_login_response(state="already-logged-in", status=409,
|
|
||||||
error="You have already logged in with your Matrix "
|
|
||||||
"account.", errcode="already-logged-in")
|
|
||||||
|
|
||||||
resp = await puppet.switch_mxid(token, user.mxid)
|
|
||||||
if resp == 2:
|
|
||||||
return self.get_mx_login_response(status=403, errcode="only-login-self",
|
|
||||||
error="You can only log in as your own Matrix user.")
|
|
||||||
elif resp == 1:
|
|
||||||
return self.get_mx_login_response(status=401, errcode="invalid-access-token",
|
|
||||||
error="Failed to verify access token.")
|
|
||||||
|
|
||||||
return self.get_mx_login_response(mxid=user.mxid, status=200, state="logged-in")
|
|
||||||
|
|
||||||
async def post_matrix_password(self, user, password):
|
|
||||||
return self.get_mx_login_response(mxid=user.mxid, status=501, error="Not yet implemented",
|
|
||||||
errcode="not-yet-implemented")
|
|
||||||
|
|
||||||
async def post_login_phone(self, user, phone):
|
|
||||||
try:
|
|
||||||
await user.client.sign_in(phone or "+123")
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="code", status=200,
|
|
||||||
message="Code requested successfully.")
|
|
||||||
except PhoneNumberInvalidError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="request", status=400,
|
|
||||||
errcode="phone_number_invalid",
|
|
||||||
error="Invalid phone number.")
|
|
||||||
except PhoneNumberBannedError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="request", status=403,
|
|
||||||
errcode="phone_number_banned",
|
|
||||||
error="Your phone number is banned from Telegram.")
|
|
||||||
except PhoneNumberAppSignupForbiddenError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="request", status=403,
|
|
||||||
errcode="phone_number_app_signup_forbidden",
|
|
||||||
error="You have disabled 3rd party apps on your account.")
|
|
||||||
except PhoneNumberUnoccupiedError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="request", status=404,
|
|
||||||
errcode="phone_number_unoccupied",
|
|
||||||
error="That phone number has not been registered.")
|
|
||||||
except PhoneNumberFloodError:
|
|
||||||
return self.get_login_response(
|
|
||||||
mxid=user.mxid, state="request", status=429, errcode="phone_number_flood",
|
|
||||||
error="Your phone number has been temporarily blocked for flooding. "
|
|
||||||
"The ban is usually applied for around a day.")
|
|
||||||
except FloodWaitError as e:
|
|
||||||
return self.get_login_response(
|
|
||||||
mxid=user.mxid, state="request", status=429, errcode="flood_wait",
|
|
||||||
error="Your phone number has been temporarily blocked for flooding. "
|
|
||||||
f"Please wait for {format_duration(e.seconds)} before trying again.")
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Error requesting phone code")
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="request", status=500,
|
|
||||||
errcode="unknown_error",
|
|
||||||
error="Internal server error while requesting code.")
|
|
||||||
|
|
||||||
async def post_login_token(self, user, token):
|
|
||||||
try:
|
|
||||||
user_info = await user.client.sign_in(bot_token=token)
|
|
||||||
asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
|
|
||||||
if user.command_status and user.command_status["action"] == "Login":
|
|
||||||
user.command_status = None
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="logged-in", status=200,
|
|
||||||
username=user_info.username)
|
|
||||||
except AccessTokenInvalidError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="token", status=401,
|
|
||||||
errcode="bot_token_invalid",
|
|
||||||
error="Bot token invalid.")
|
|
||||||
except AccessTokenExpiredError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="token", status=403,
|
|
||||||
errcode="bot_token_expired",
|
|
||||||
error="Bot token expired.")
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Error sending bot token")
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="token", status=500,
|
|
||||||
error="Internal server error while sending token.")
|
|
||||||
|
|
||||||
async def post_login_code(self, user, code, password_in_data):
|
|
||||||
try:
|
|
||||||
user_info = await user.client.sign_in(code=code)
|
|
||||||
asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
|
|
||||||
if user.command_status and user.command_status["action"] == "Login":
|
|
||||||
user.command_status = None
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="logged-in", status=200,
|
|
||||||
username=user_info.username)
|
|
||||||
except PhoneCodeInvalidError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="code", status=401,
|
|
||||||
errcode="phone_code_invalid",
|
|
||||||
error="Incorrect phone code.")
|
|
||||||
except PhoneCodeExpiredError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="code", status=403,
|
|
||||||
errcode="phone_code_expired",
|
|
||||||
error="Phone code expired.")
|
|
||||||
except SessionPasswordNeededError:
|
|
||||||
if not password_in_data:
|
|
||||||
if user.command_status and user.command_status["action"] == "Login":
|
|
||||||
user.command_status = {
|
|
||||||
"next": enter_password,
|
|
||||||
"action": "Login (password entry)",
|
|
||||||
}
|
|
||||||
return self.get_login_response(
|
|
||||||
mxid=user.mxid, state="password", status=202,
|
|
||||||
message="Code accepted, but you have 2-factor authentication is enabled.")
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Error sending phone code")
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="code", status=500,
|
|
||||||
errcode="unknown_error",
|
|
||||||
error="Internal server error while sending code.")
|
|
||||||
|
|
||||||
async def post_login_password(self, user, password):
|
|
||||||
try:
|
|
||||||
user_info = await user.client.sign_in(password=password)
|
|
||||||
asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
|
|
||||||
if user.command_status and user.command_status["action"] == "Login (password entry)":
|
|
||||||
user.command_status = None
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="logged-in", status=200,
|
|
||||||
username=user_info.username)
|
|
||||||
except PasswordEmptyError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="password", status=400,
|
|
||||||
errcode="password_empty",
|
|
||||||
error="Empty password.")
|
|
||||||
except PasswordHashInvalidError:
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="password", status=401,
|
|
||||||
errcode="password_invalid",
|
|
||||||
error="Incorrect password.")
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Error sending password")
|
|
||||||
return self.get_login_response(mxid=user.mxid, state="password", status=500,
|
|
||||||
errcode="unknown_error",
|
|
||||||
error="Internal server error while sending password.")
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from aiohttp import web
|
|
||||||
from typing import Tuple, Optional, Callable, Awaitable, TYPE_CHECKING
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
|
|
||||||
from telethon.utils import get_peer_id, resolve_id
|
|
||||||
from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat
|
|
||||||
from mautrix_appservice import AppService, MatrixRequestError, IntentError
|
|
||||||
|
|
||||||
from ...user import User
|
|
||||||
from ...portal import Portal
|
|
||||||
from ...commands.portal import user_has_power_level, get_initial_state
|
|
||||||
from ..common import AuthAPI
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ...context import Context
|
|
||||||
|
|
||||||
|
|
||||||
class ProvisioningAPI(AuthAPI):
|
|
||||||
log = logging.getLogger("mau.web.provisioning")
|
|
||||||
|
|
||||||
def __init__(self, context: "Context"):
|
|
||||||
super().__init__(context.loop)
|
|
||||||
self.secret = context.config["appservice.provisioning.shared_secret"]
|
|
||||||
self.az = context.az # type: AppService
|
|
||||||
self.context = context # type: Context
|
|
||||||
|
|
||||||
self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware])
|
|
||||||
|
|
||||||
portal_prefix = "/portal/{mxid:![^/]+}"
|
|
||||||
self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid)
|
|
||||||
self.app.router.add_route("GET", "/portal/{tgid:-[0-9]+}", self.get_portal_by_tgid)
|
|
||||||
self.app.router.add_route("POST", portal_prefix + "/connect/{chat_id:[0-9]+}",
|
|
||||||
self.connect_chat)
|
|
||||||
self.app.router.add_route("POST", f"{portal_prefix}/create", self.create_chat)
|
|
||||||
self.app.router.add_route("POST", f"{portal_prefix}/disconnect", self.disconnect_chat)
|
|
||||||
|
|
||||||
user_prefix = "/user/{mxid:@[^:]*:[^/]+}"
|
|
||||||
self.app.router.add_route("GET", f"{user_prefix}", self.get_user_info)
|
|
||||||
self.app.router.add_route("GET", f"{user_prefix}/chats", self.get_chats)
|
|
||||||
|
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/logout", self.logout)
|
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/login/bot_token", self.send_bot_token)
|
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/login/request_code", self.request_code)
|
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/login/send_code", self.send_code)
|
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/login/send_password", self.send_password)
|
|
||||||
|
|
||||||
async def get_portal_by_mxid(self, request: web.Request) -> web.Response:
|
|
||||||
err = self.check_authorization(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
mxid = request.match_info["mxid"]
|
|
||||||
portal = Portal.get_by_mxid(mxid)
|
|
||||||
if not portal:
|
|
||||||
return self.get_error_response(404, "portal_not_found",
|
|
||||||
"Portal with given Matrix ID not found.")
|
|
||||||
return web.json_response({
|
|
||||||
"mxid": portal.mxid,
|
|
||||||
"chat_id": get_peer_id(portal.peer),
|
|
||||||
"peer_type": portal.peer_type,
|
|
||||||
"title": portal.title,
|
|
||||||
"about": portal.about,
|
|
||||||
"username": portal.username,
|
|
||||||
"megagroup": portal.megagroup,
|
|
||||||
})
|
|
||||||
|
|
||||||
async def get_portal_by_tgid(self, request: web.Request) -> web.Response:
|
|
||||||
err = self.check_authorization(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
try:
|
|
||||||
tgid, _ = resolve_id(int(request.match_info["tgid"]))
|
|
||||||
except ValueError:
|
|
||||||
return self.get_error_response(400, "tgid_invalid",
|
|
||||||
"Given chat ID is not valid.")
|
|
||||||
portal = Portal.get_by_tgid(tgid)
|
|
||||||
if not portal:
|
|
||||||
return self.get_error_response(404, "portal_not_found",
|
|
||||||
"Portal to given Telegram chat not found.")
|
|
||||||
return web.json_response({
|
|
||||||
"mxid": portal.mxid,
|
|
||||||
"chat_id": get_peer_id(portal.peer),
|
|
||||||
"peer_type": portal.peer_type,
|
|
||||||
"title": portal.title,
|
|
||||||
"about": portal.about,
|
|
||||||
"username": portal.username,
|
|
||||||
"megagroup": portal.megagroup,
|
|
||||||
})
|
|
||||||
|
|
||||||
async def connect_chat(self, request: web.Request) -> web.Response:
|
|
||||||
err = self.check_authorization(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
room_id = request.match_info["mxid"]
|
|
||||||
if Portal.get_by_mxid(room_id):
|
|
||||||
return self.get_error_response(409, "room_already_bridged",
|
|
||||||
"Room is already bridged to another Telegram chat.")
|
|
||||||
|
|
||||||
chat_id = request.match_info["chat_id"]
|
|
||||||
if chat_id.startswith("-100"):
|
|
||||||
tgid = int(chat_id[4:])
|
|
||||||
peer_type = "channel"
|
|
||||||
elif chat_id.startswith("-"):
|
|
||||||
tgid = -int(chat_id)
|
|
||||||
peer_type = "chat"
|
|
||||||
else:
|
|
||||||
return self.get_error_response(400, "tgid_invalid", "Invalid Telegram chat ID.")
|
|
||||||
|
|
||||||
user, err = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
|
|
||||||
require_puppeting=False)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
elif user and not await user_has_power_level(room_id, self.az.intent, user, "bridge"):
|
|
||||||
return self.get_error_response(403, "not_enough_permissions",
|
|
||||||
"You do not have the permissions to bridge that room.")
|
|
||||||
|
|
||||||
portal = Portal.get_by_tgid(tgid, peer_type=peer_type)
|
|
||||||
if portal.mxid == room_id:
|
|
||||||
return self.get_error_response(200, "bridge_exists",
|
|
||||||
"Telegram chat is already bridged to that Matrix room.")
|
|
||||||
elif portal.mxid:
|
|
||||||
force = request.query.get("force", None)
|
|
||||||
if force in ("delete", "unbridge"):
|
|
||||||
delete = force == "delete"
|
|
||||||
await portal.cleanup_room(portal.main_intent, portal.mxid, puppets_only=not delete,
|
|
||||||
message=("Portal deleted (moving to another room)"
|
|
||||||
if delete
|
|
||||||
else "Room unbridged (portal moving to another "
|
|
||||||
"room)"))
|
|
||||||
else:
|
|
||||||
return self.get_error_response(409, "chat_already_bridged",
|
|
||||||
"Telegram chat is already bridged to another "
|
|
||||||
"Matrix room.")
|
|
||||||
|
|
||||||
is_logged_in = user is not None and await user.is_logged_in()
|
|
||||||
user = user if is_logged_in else self.context.bot
|
|
||||||
if not user:
|
|
||||||
return self.get_login_response(status=403, errcode="not_logged_in",
|
|
||||||
error="You are not logged in and there is no relay bot.")
|
|
||||||
|
|
||||||
entity = None # type: Optional[TypeChat]
|
|
||||||
try:
|
|
||||||
entity = await user.client.get_entity(portal.peer)
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
|
|
||||||
|
|
||||||
if not entity or isinstance(entity, (ChatForbidden, ChannelForbidden)):
|
|
||||||
if is_logged_in:
|
|
||||||
return self.get_error_response(403, "user_not_in_chat",
|
|
||||||
"Failed to get info of Telegram chat. "
|
|
||||||
"Are you in the chat?")
|
|
||||||
return self.get_error_response(403, "bot_not_in_chat",
|
|
||||||
"Failed to get info of Telegram chat. "
|
|
||||||
"Is the relay bot in the chat?")
|
|
||||||
|
|
||||||
direct = False
|
|
||||||
|
|
||||||
portal.mxid = room_id
|
|
||||||
portal.title, portal.about, levels = await get_initial_state(self.az.intent, room_id)
|
|
||||||
portal.photo_id = ""
|
|
||||||
portal.save()
|
|
||||||
|
|
||||||
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
|
|
||||||
loop=self.loop)
|
|
||||||
|
|
||||||
return web.Response(status=202, body="{}")
|
|
||||||
|
|
||||||
async def create_chat(self, request: web.Request) -> web.Response:
|
|
||||||
err = self.check_authorization(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
data = await self.get_data(request)
|
|
||||||
if not data:
|
|
||||||
return self.get_error_response(400, "json_invalid", "Invalid JSON.")
|
|
||||||
|
|
||||||
room_id = request.match_info["mxid"]
|
|
||||||
if Portal.get_by_mxid(room_id):
|
|
||||||
return self.get_error_response(409, "room_already_bridged",
|
|
||||||
"Room is already bridged to another Telegram chat.")
|
|
||||||
|
|
||||||
user, err = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
|
|
||||||
require_puppeting=False)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
elif not await user.is_logged_in() or user.is_bot:
|
|
||||||
return self.get_error_response(403, "not_logged_in_real_account",
|
|
||||||
"You are not logged in with a real account.")
|
|
||||||
elif not await user_has_power_level(room_id, self.az.intent, user, "bridge"):
|
|
||||||
return self.get_error_response(403, "not_enough_permissions",
|
|
||||||
"You do not have the permissions to bridge that room.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
title, about, _ = await get_initial_state(self.az.intent, room_id)
|
|
||||||
except (MatrixRequestError, IntentError):
|
|
||||||
return self.get_error_response(403, "bot_not_in_room",
|
|
||||||
"The bridge bot is not in the given room.")
|
|
||||||
|
|
||||||
about = data.get("about", about)
|
|
||||||
|
|
||||||
title = data.get("title", title)
|
|
||||||
if len(title) == 0:
|
|
||||||
return self.get_error_response(400, "body_value_invalid", "Title can not be empty.")
|
|
||||||
|
|
||||||
type = data.get("type", "")
|
|
||||||
if type not in ("group", "chat", "supergroup", "channel"):
|
|
||||||
return self.get_error_response(400, "body_value_invalid",
|
|
||||||
"Given chat type is not valid.")
|
|
||||||
|
|
||||||
supergroup = type == "supergroup"
|
|
||||||
type = {
|
|
||||||
"supergroup": "channel",
|
|
||||||
"channel": "channel",
|
|
||||||
"chat": "chat",
|
|
||||||
"group": "chat",
|
|
||||||
}[type]
|
|
||||||
|
|
||||||
portal = Portal(tgid=None, mxid=room_id, title=title, about=about, peer_type=type)
|
|
||||||
try:
|
|
||||||
await portal.create_telegram_chat(user, supergroup=supergroup)
|
|
||||||
except ValueError as e:
|
|
||||||
portal.delete()
|
|
||||||
return self.get_error_response(500, "unknown_error", e.args[0])
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
"chat_id": portal.tgid,
|
|
||||||
}, status=201)
|
|
||||||
|
|
||||||
async def disconnect_chat(self, request: web.Request) -> web.Response:
|
|
||||||
err = self.check_authorization(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
portal = Portal.get_by_mxid(request.match_info["mxid"])
|
|
||||||
if not portal or not portal.tgid:
|
|
||||||
return self.get_error_response(404, "portal_not_found",
|
|
||||||
"Room is not a portal.")
|
|
||||||
|
|
||||||
user, err = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
|
|
||||||
require_puppeting=False, require_user=False)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
elif user and not await user_has_power_level(portal.mxid, self.az.intent, user, "unbridge"):
|
|
||||||
return self.get_error_response(403, "not_enough_permissions",
|
|
||||||
"You do not have the permissions to unbridge that room.")
|
|
||||||
|
|
||||||
delete = request.query.get("delete", "").lower() in ("true", "t", "1", "yes", "y")
|
|
||||||
sync = request.query.get("delete", "").lower() in ("true", "t", "1", "yes", "y")
|
|
||||||
|
|
||||||
coro = portal.cleanup_and_delete() if delete else portal.unbridge()
|
|
||||||
if sync:
|
|
||||||
try:
|
|
||||||
await coro
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Failed to disconnect chat")
|
|
||||||
return self.get_error_response(500, "exception", "Failed to disconnect chat")
|
|
||||||
else:
|
|
||||||
asyncio.ensure_future(coro, loop=self.loop)
|
|
||||||
return web.json_response({}, status=200 if sync else 202)
|
|
||||||
|
|
||||||
async def get_user_info(self, request: web.Request) -> web.Response:
|
|
||||||
data, user, err = await self.get_user_request_info(request, expect_logged_in=None,
|
|
||||||
require_puppeting=False)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
user_data = None
|
|
||||||
if await user.is_logged_in():
|
|
||||||
me = await user.client.get_me()
|
|
||||||
await user.update_info(me)
|
|
||||||
user_data = {
|
|
||||||
"id": user.tgid,
|
|
||||||
"username": user.username,
|
|
||||||
"first_name": me.first_name,
|
|
||||||
"last_name": me.last_name,
|
|
||||||
"phone": me.phone,
|
|
||||||
"is_bot": user.is_bot,
|
|
||||||
}
|
|
||||||
return web.json_response({
|
|
||||||
"telegram": user_data,
|
|
||||||
"mxid": user.mxid,
|
|
||||||
"permissions": user.permissions,
|
|
||||||
})
|
|
||||||
|
|
||||||
async def get_chats(self, request: web.Request) -> web.Response:
|
|
||||||
data, user, err = await self.get_user_request_info(request, expect_logged_in=True)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
if not user.is_bot:
|
|
||||||
chats = await user.get_dialogs()
|
|
||||||
return web.json_response([{
|
|
||||||
"id": get_peer_id(chat),
|
|
||||||
"title": chat.title,
|
|
||||||
} for chat in chats])
|
|
||||||
else:
|
|
||||||
return web.json_response([{
|
|
||||||
"id": get_peer_id(chat.peer),
|
|
||||||
"title": chat.title,
|
|
||||||
} for chat in user.portals.values() if chat.tgid])
|
|
||||||
|
|
||||||
async def send_bot_token(self, request: web.Request) -> web.Response:
|
|
||||||
data, user, err = await self.get_user_request_info(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
return await self.post_login_token(user, data.get("token", ""))
|
|
||||||
|
|
||||||
async def request_code(self, request: web.Request) -> web.Response:
|
|
||||||
data, user, err = await self.get_user_request_info(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
return await self.post_login_phone(user, data.get("phone", ""))
|
|
||||||
|
|
||||||
async def send_code(self, request: web.Request) -> web.Response:
|
|
||||||
data, user, err = await self.get_user_request_info(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
return await self.post_login_code(user, data.get("code", 0), password_in_data=False)
|
|
||||||
|
|
||||||
async def send_password(self, request: web.Request) -> web.Response:
|
|
||||||
data, user, err = await self.get_user_request_info(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
return await self.post_login_password(user, data.get("password", ""))
|
|
||||||
|
|
||||||
async def logout(self, request: web.Request) -> web.Response:
|
|
||||||
_, user, err = await self.get_user_request_info(request, expect_logged_in=True,
|
|
||||||
require_puppeting=False,
|
|
||||||
want_data=False)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
await user.log_out()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def error_middleware(_, handler) -> Callable[[web.Request], Awaitable[web.Response]]:
|
|
||||||
async def middleware_handler(request: web.Request) -> web.Response:
|
|
||||||
try:
|
|
||||||
return await handler(request)
|
|
||||||
except web.HTTPException as ex:
|
|
||||||
return web.json_response({
|
|
||||||
"error": f"Unhandled HTTP {ex.status}",
|
|
||||||
"errcode": f"unhandled_http_{ex.status}",
|
|
||||||
}, status=ex.status)
|
|
||||||
|
|
||||||
return middleware_handler
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_error_response(status=200, errcode="", error="") -> web.Response:
|
|
||||||
return web.json_response({
|
|
||||||
"error": error,
|
|
||||||
"errcode": errcode,
|
|
||||||
}, status=status)
|
|
||||||
|
|
||||||
def get_mx_login_response(self, status=200, state="", username="", mxid="", message="",
|
|
||||||
error="", errcode=""):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def get_login_response(self, status=200, state="", username="", mxid="", message="", error="",
|
|
||||||
errcode="") -> web.Response:
|
|
||||||
if username:
|
|
||||||
resp = {
|
|
||||||
"state": "logged-in",
|
|
||||||
"username": username,
|
|
||||||
}
|
|
||||||
elif message:
|
|
||||||
resp = {
|
|
||||||
"state": state,
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
resp = {
|
|
||||||
"error": error,
|
|
||||||
"errcode": errcode,
|
|
||||||
}
|
|
||||||
if state:
|
|
||||||
resp["state"] = state
|
|
||||||
return web.json_response(resp, status=status)
|
|
||||||
|
|
||||||
def check_authorization(self, request: web.Request) -> Optional[web.Response]:
|
|
||||||
auth = request.headers.get("Authorization", "")
|
|
||||||
if auth != f"Bearer {self.secret}":
|
|
||||||
return self.get_error_response(error="Shared secret is not valid.",
|
|
||||||
errcode="shared_secret_invalid",
|
|
||||||
status=401)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_data(request: web.Request) -> Optional[dict]:
|
|
||||||
try:
|
|
||||||
return await request.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_user(self, mxid: str, expect_logged_in: Optional[bool] = False,
|
|
||||||
require_puppeting: bool = True, require_user: bool = True
|
|
||||||
) -> Tuple[Optional[User], Optional[web.Response]]:
|
|
||||||
if not mxid:
|
|
||||||
if not require_user:
|
|
||||||
return None, None
|
|
||||||
return None, self.get_login_response(error="User ID not given.",
|
|
||||||
errcode="mxid_empty", status=400)
|
|
||||||
|
|
||||||
user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True)
|
|
||||||
if require_puppeting and not user.puppet_whitelisted:
|
|
||||||
return user, self.get_login_response(error="You are not whitelisted.",
|
|
||||||
errcode="mxid_not_whitelisted", status=403)
|
|
||||||
if expect_logged_in is not None:
|
|
||||||
logged_in = await user.is_logged_in()
|
|
||||||
if not expect_logged_in and logged_in:
|
|
||||||
return user, self.get_login_response(username=user.username, status=409,
|
|
||||||
error="You are already logged in.",
|
|
||||||
errcode="already_logged_in")
|
|
||||||
elif expect_logged_in and not logged_in:
|
|
||||||
return user, self.get_login_response(status=403, error="You are not logged in.",
|
|
||||||
errcode="not_logged_in")
|
|
||||||
return user, None
|
|
||||||
|
|
||||||
async def get_user_request_info(self, request: web.Request,
|
|
||||||
expect_logged_in: Optional[bool] = False,
|
|
||||||
require_puppeting: bool = False,
|
|
||||||
want_data: bool = True,
|
|
||||||
) -> (Tuple[Optional[dict],
|
|
||||||
Optional[User],
|
|
||||||
Optional[web.Response]]):
|
|
||||||
err = self.check_authorization(request)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
|
|
||||||
data = None
|
|
||||||
if want_data and (request.method == "POST" or request.method == "PUT"):
|
|
||||||
data = await self.get_data(request)
|
|
||||||
if not data:
|
|
||||||
return None, None, self.get_login_response(error="Invalid JSON.",
|
|
||||||
errcode="json_invalid", status=400)
|
|
||||||
|
|
||||||
mxid = request.match_info["mxid"]
|
|
||||||
user, err = await self.get_user(mxid, expect_logged_in, require_puppeting)
|
|
||||||
|
|
||||||
return data, user, err
|
|
||||||
@@ -1,861 +0,0 @@
|
|||||||
swagger: "2.0"
|
|
||||||
|
|
||||||
info:
|
|
||||||
title: Mautrix-Telegram provisioning
|
|
||||||
version: 0.3.0
|
|
||||||
description: The provisioning API for Mautrix-Telegram, the Matrix-Telegram puppeting/relaybot bridge.
|
|
||||||
license:
|
|
||||||
name: AGPLv3
|
|
||||||
url: https://github.com/tulir/mautrix-telegram/blob/master/LICENSE
|
|
||||||
|
|
||||||
externalDocs:
|
|
||||||
description: Provisioning API wiki page on GitHub
|
|
||||||
url: https://github.com/tulir/mautrix-telegram/wiki/Provisioning-API
|
|
||||||
|
|
||||||
basePath: /_matrix/provision/v1
|
|
||||||
|
|
||||||
schemes: [https]
|
|
||||||
consumes: [application/json]
|
|
||||||
produces: [application/json]
|
|
||||||
|
|
||||||
tags:
|
|
||||||
- name: User info
|
|
||||||
- name: Authentication
|
|
||||||
- name: Bridging
|
|
||||||
|
|
||||||
paths:
|
|
||||||
/portal/{room_id}:
|
|
||||||
get:
|
|
||||||
operationId: get_portal
|
|
||||||
summary: Get the bridging status and info of the connected Telegram chat
|
|
||||||
tags: [Bridging]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: Room is bridged
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/PortalInfo"
|
|
||||||
400:
|
|
||||||
$ref: "#/responses/BadRequest"
|
|
||||||
404:
|
|
||||||
description: Unknown portal
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- portal_not_found
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
parameters:
|
|
||||||
- name: room_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the room whose bridging status to get
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
pattern: "![^/]+"
|
|
||||||
/portal/{chat_id}:
|
|
||||||
get:
|
|
||||||
operationId: get_portal_by_tgid
|
|
||||||
summary: Get the bridging status and info of the connected Telegram chat
|
|
||||||
tags: [Bridging]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: Chat is bridged
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/PortalInfo"
|
|
||||||
400:
|
|
||||||
description: Invalid Telegram chat ID
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- tgid_invalid
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
404:
|
|
||||||
description: Unknown portal
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- portal_not_found
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
parameters:
|
|
||||||
- name: chat_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the room whose bridging status to get
|
|
||||||
required: true
|
|
||||||
type: integer
|
|
||||||
pattern: "-[0-9]+"
|
|
||||||
/portal/{room_id}/connect/{chat_id}:
|
|
||||||
post:
|
|
||||||
operationId: connect_portal
|
|
||||||
summary: Connect an existing Telegram chat to the given room
|
|
||||||
tags: [Bridging]
|
|
||||||
parameters:
|
|
||||||
- name: room_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the room to which the Telegram chat should be connected
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
- name: chat_id
|
|
||||||
in: path
|
|
||||||
description: The ID of the Telegram chat to connect
|
|
||||||
required: true
|
|
||||||
type: integer
|
|
||||||
pattern: "-[0-9]+"
|
|
||||||
- name: force
|
|
||||||
in: query
|
|
||||||
description: Set to force bridging by unbridging or deleting existing portal rooms.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- delete
|
|
||||||
- unbridge
|
|
||||||
- name: user_id
|
|
||||||
in: query
|
|
||||||
description: Optional Matrix user ID to check if the user has permissions to do the bridging.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: Telegram chat was already bridged to given room.
|
|
||||||
202:
|
|
||||||
description: Room bridging initiated
|
|
||||||
400:
|
|
||||||
$ref: "#/responses/BadRequest"
|
|
||||||
403:
|
|
||||||
description: "Given user doesn't have permission to bridge the room, or the bridge bot is not in the room"
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- not_enough_permissions
|
|
||||||
- bot_not_in_room
|
|
||||||
- bot_not_in_chat
|
|
||||||
- not_logged_in
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
409:
|
|
||||||
description: Matrix room or Telegram chat is already bridged to another chat/room
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
example: <room|chat>_already_bridged
|
|
||||||
enum:
|
|
||||||
- room_already_bridged
|
|
||||||
- chat_already_bridged
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
/portal/{room_id}/create:
|
|
||||||
post:
|
|
||||||
operationId: create_portal
|
|
||||||
summary: Create a new Telegram chat for the given room
|
|
||||||
tags: [Bridging]
|
|
||||||
responses:
|
|
||||||
201:
|
|
||||||
description: Telegram chat created
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
chat_id:
|
|
||||||
type: integer
|
|
||||||
400:
|
|
||||||
$ref: "#/responses/BadRequest"
|
|
||||||
403:
|
|
||||||
description: "Given user isn't logged in with a real account or doesn't have permission to bridge the room, or the bridge bot is not in the room"
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- not_logged_in_real_account
|
|
||||||
- not_enough_permissions
|
|
||||||
- bot_not_in_room
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
409:
|
|
||||||
description: Room is already bridged
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- room_already_bridged
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
parameters:
|
|
||||||
- name: room_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the room whose bridging status to get
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
- name: body
|
|
||||||
in: body
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [type]
|
|
||||||
properties:
|
|
||||||
type:
|
|
||||||
description: The type of chat to create
|
|
||||||
type: string
|
|
||||||
example: supergroup
|
|
||||||
enum:
|
|
||||||
- chat
|
|
||||||
- supergroup
|
|
||||||
- channel
|
|
||||||
title:
|
|
||||||
description: Title for the new chat
|
|
||||||
type: string
|
|
||||||
example: Mautrix-Telegram Bridge
|
|
||||||
about:
|
|
||||||
description: About text for the new chat
|
|
||||||
type: string
|
|
||||||
example: Discussion about mautrix-telegram
|
|
||||||
- name: user_id
|
|
||||||
in: query
|
|
||||||
description: Matrix user to create the chat as.
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
/portal/{room_id}/disconnect:
|
|
||||||
post:
|
|
||||||
operationId: disconnect_portal
|
|
||||||
summary: Disconnect the Telegram chat from the room
|
|
||||||
tags: [Bridging]
|
|
||||||
responses:
|
|
||||||
202:
|
|
||||||
description: Room unbridging initiated
|
|
||||||
400:
|
|
||||||
$ref: "#/responses/BadRequest"
|
|
||||||
403:
|
|
||||||
$ref: "#/responses/PermissionError"
|
|
||||||
404:
|
|
||||||
description: Unknown portal
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- portal_not_found
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
parameters:
|
|
||||||
- name: room_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the room whose bridging status to get
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
- name: user_id
|
|
||||||
in: query
|
|
||||||
description: Optional Matrix user ID to check if the user has permissions to do the bridging.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
- name: delete
|
|
||||||
in: query
|
|
||||||
description: Whether or not to delete the room completely (kick all users instead of just Telegram puppets)
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
- name: sync
|
|
||||||
in: query
|
|
||||||
description: Whether or not to wait for the unbridging to be completed before responding. **Could cause timeouts in large rooms**
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
/user/{user_id}:
|
|
||||||
get:
|
|
||||||
operationId: get_me
|
|
||||||
summary: Get the info of the Telegram user the given Matrix user is logged in as
|
|
||||||
tags: [User info]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: User found
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/UserInfo"
|
|
||||||
400:
|
|
||||||
$ref: "#/responses/BadRequest"
|
|
||||||
403:
|
|
||||||
$ref: "#/responses/NotWhitelistedError"
|
|
||||||
500:
|
|
||||||
$ref: "#/responses/UnknownError"
|
|
||||||
parameters:
|
|
||||||
- name: user_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the user who to log in as
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
/user/{user_id}/chats:
|
|
||||||
get:
|
|
||||||
operationId: get_chats
|
|
||||||
summary: Get the list of Telegram chats the given Matrix user has access to
|
|
||||||
tags: [User info]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: User is logged in
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/UserChats"
|
|
||||||
400:
|
|
||||||
$ref: "#/responses/BadRequest"
|
|
||||||
403:
|
|
||||||
description: User is not logged in or not whitelisted
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- not_logged_in
|
|
||||||
- mxid_not_whitelisted
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
500:
|
|
||||||
$ref: "#/responses/UnknownError"
|
|
||||||
parameters:
|
|
||||||
- name: user_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the user who to log in as
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
/user/{user_id}/login/bot_token:
|
|
||||||
post:
|
|
||||||
operationId: post_bot_token
|
|
||||||
summary: Log in with a bot token
|
|
||||||
tags: [Authentication]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: Login successful
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/AuthSuccess"
|
|
||||||
400:
|
|
||||||
$ref: "#/responses/BadRequest"
|
|
||||||
401:
|
|
||||||
description: Invalid or expired bot token or invalid shared secret
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
example: bot_token_<error>
|
|
||||||
enum:
|
|
||||||
- bot_token_invalid
|
|
||||||
- bot_token_expired
|
|
||||||
- shared_secret_invalid
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
403:
|
|
||||||
$ref: "#/responses/NotWhitelistedError"
|
|
||||||
409:
|
|
||||||
$ref: "#/responses/AlreadyLoggedInError"
|
|
||||||
500:
|
|
||||||
$ref: "#/responses/UnknownError"
|
|
||||||
parameters:
|
|
||||||
- name: user_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the user who to log in as
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
- name: body
|
|
||||||
in: body
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
token:
|
|
||||||
type: string
|
|
||||||
description: The access token of the bot to log in as
|
|
||||||
example: "297900271:IXjeGEcAN61zHnjPgkWnYWyvVp9K4ulHBEv"
|
|
||||||
/user/{user_id}/login/request_code:
|
|
||||||
post:
|
|
||||||
operationId: post_login_phone
|
|
||||||
summary: Request a phone code from Telegram
|
|
||||||
tags: [Authentication]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: Code requested successfully
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/AuthSuccess"
|
|
||||||
400:
|
|
||||||
description: Invalid phone number or JSON
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
example: machine_readable_error
|
|
||||||
enum:
|
|
||||||
- phone_number_invalid
|
|
||||||
- json_invalid
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
401:
|
|
||||||
description: Invalid shared secret
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- shared_secret_invalid
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
403:
|
|
||||||
description: Matrix ID is not whitelisted or phone number is banned or has forbidden 3rd party apps
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
example: machine_readable_error
|
|
||||||
enum:
|
|
||||||
- mxid_not_whitelisted
|
|
||||||
- phone_number_banned
|
|
||||||
- phone_number_app_signup_forbidden
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
404:
|
|
||||||
description: Unregistered phone number
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- phone_number_unoccupied
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
409:
|
|
||||||
$ref: "#/responses/AlreadyLoggedInError"
|
|
||||||
429:
|
|
||||||
description: Phone number has been temporarily blocked for flooding
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- flood_wait
|
|
||||||
- phone_number_flood
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
500:
|
|
||||||
$ref: "#/responses/UnknownError"
|
|
||||||
parameters:
|
|
||||||
- name: user_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the user who to log in as
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
- name: body
|
|
||||||
in: body
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
phone:
|
|
||||||
type: string
|
|
||||||
description: The phone number to log in as.
|
|
||||||
example: "+123456789"
|
|
||||||
/user/{user_id}/login/send_code:
|
|
||||||
post:
|
|
||||||
operationId: post_login_code
|
|
||||||
summary: Send the login code
|
|
||||||
tags: [Authentication]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: Login successful
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/AuthSuccess"
|
|
||||||
202:
|
|
||||||
description: Correct code, but two-factor authentication is enabled
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/AuthSuccess"
|
|
||||||
400:
|
|
||||||
$ref: "#/responses/BadRequest"
|
|
||||||
401:
|
|
||||||
description: Invalid phone code or shared secret
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- phone_code_invalid
|
|
||||||
- shared_secret_invalid
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
403:
|
|
||||||
description: Matrix ID not whitelisted or phone code expired
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
example: machine_readable_error
|
|
||||||
enum:
|
|
||||||
- mxid_not_whitelisted
|
|
||||||
- phone_code_expired
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
409:
|
|
||||||
$ref: "#/responses/AlreadyLoggedInError"
|
|
||||||
500:
|
|
||||||
$ref: "#/responses/UnknownError"
|
|
||||||
parameters:
|
|
||||||
- name: user_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the user who to log in as
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
- name: body
|
|
||||||
in: body
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
code:
|
|
||||||
type: integer
|
|
||||||
description: The phone code from Telegram.
|
|
||||||
format: int32
|
|
||||||
example: 123456
|
|
||||||
/user/{user_id}/login/send_password:
|
|
||||||
post:
|
|
||||||
operationId: post_login_password
|
|
||||||
summary: Send the two-factor auth password
|
|
||||||
tags: [Authentication]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: Login successful
|
|
||||||
schema:
|
|
||||||
$ref: "#/definitions/AuthSuccess"
|
|
||||||
400:
|
|
||||||
description: Missing password or invalid JSON
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
example: <field>_empty
|
|
||||||
enum:
|
|
||||||
- password_empty
|
|
||||||
- json_invalid
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
401:
|
|
||||||
description: Incorrect password or invalid shared secret
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- password_invalid
|
|
||||||
- shared_secret_invalid
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
403:
|
|
||||||
$ref: "#/responses/NotWhitelistedError"
|
|
||||||
409:
|
|
||||||
$ref: "#/responses/AlreadyLoggedInError"
|
|
||||||
500:
|
|
||||||
$ref: "#/responses/UnknownError"
|
|
||||||
parameters:
|
|
||||||
- name: user_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the user who to log in as
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
- name: body
|
|
||||||
in: body
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
password:
|
|
||||||
type: string
|
|
||||||
description: The two-factor auth password
|
|
||||||
format: password
|
|
||||||
example: hunter2
|
|
||||||
/user/{user_id}/logout:
|
|
||||||
post:
|
|
||||||
operationId: logout
|
|
||||||
summary: Log out
|
|
||||||
tags: [Authentication]
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: Logout successful
|
|
||||||
403:
|
|
||||||
description: User was not logged in
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- not_logged_in
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
500:
|
|
||||||
$ref: "#/responses/UnknownError"
|
|
||||||
parameters:
|
|
||||||
- name: user_id
|
|
||||||
in: path
|
|
||||||
description: The Matrix ID of the user who to log out as
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
responses:
|
|
||||||
NotWhitelistedError:
|
|
||||||
description: Matrix ID not whitelisted for puppeting
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- mxid_not_whitelisted
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
AlreadyLoggedInError:
|
|
||||||
description: The Matrix user is already logged in
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
state:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- logged-in
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
description: The Telegram username the user is logged in as.
|
|
||||||
BadRequest:
|
|
||||||
description: Invalid JSON.
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- json_invalid
|
|
||||||
- mxid_empty
|
|
||||||
- body_value_missing
|
|
||||||
- body_value_invalid
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
UnknownError:
|
|
||||||
description: Unknown error
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: UnknownError
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
enum:
|
|
||||||
- unknown_error
|
|
||||||
- unhandled_error
|
|
||||||
error:
|
|
||||||
type: string
|
|
||||||
title: Error
|
|
||||||
description: A human-readable description of the error
|
|
||||||
example: Internal server error while <action>.
|
|
||||||
PermissionError:
|
|
||||||
description: The given Matrix user doesn't have the permissions to do that.
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
title: Error
|
|
||||||
properties:
|
|
||||||
errcode:
|
|
||||||
type: string
|
|
||||||
title: Error code
|
|
||||||
description: A machine-readable error code
|
|
||||||
example: not_enough_permissions
|
|
||||||
enum:
|
|
||||||
- not_enough_permissions
|
|
||||||
error:
|
|
||||||
$ref: "#/definitions/HumanReadableError"
|
|
||||||
|
|
||||||
definitions:
|
|
||||||
UserInfo:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
mxid:
|
|
||||||
type: string
|
|
||||||
example: "@usern:example.com"
|
|
||||||
permissions:
|
|
||||||
type: string
|
|
||||||
example: user
|
|
||||||
enum:
|
|
||||||
- none
|
|
||||||
- relaybot
|
|
||||||
- user
|
|
||||||
- full
|
|
||||||
- admin
|
|
||||||
telegram:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: integer
|
|
||||||
example: 123456789
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
example: username
|
|
||||||
first_name:
|
|
||||||
type: string
|
|
||||||
example: Usern
|
|
||||||
last_name:
|
|
||||||
type: string
|
|
||||||
example: A.
|
|
||||||
phone:
|
|
||||||
type: string
|
|
||||||
example: +123456789
|
|
||||||
is_bot:
|
|
||||||
type: boolean
|
|
||||||
example: false
|
|
||||||
UserChats:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: integer
|
|
||||||
example: -123456789
|
|
||||||
description: A bot API style chat ID.
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
PortalInfo:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
mxid:
|
|
||||||
type: string
|
|
||||||
example: "!foo:example.com"
|
|
||||||
chat_id:
|
|
||||||
type: integer
|
|
||||||
example: -100123456789
|
|
||||||
peer_type:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- user
|
|
||||||
- chat
|
|
||||||
- channel
|
|
||||||
megagroup:
|
|
||||||
type: boolean
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
about:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
AuthSuccess:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
state:
|
|
||||||
type: string
|
|
||||||
description: The state/next step after the successful operation.
|
|
||||||
enum:
|
|
||||||
- code
|
|
||||||
- request
|
|
||||||
- password
|
|
||||||
- token
|
|
||||||
- logged-in
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
description: The Telegram username the user is logged in as. Only applicable if state=logged-in
|
|
||||||
|
|
||||||
HumanReadableError:
|
|
||||||
type: string
|
|
||||||
description: A human-readable description of the error
|
|
||||||
example: A human-readable description of the error
|
|
||||||
|
|
||||||
security:
|
|
||||||
- Bearer: []
|
|
||||||
securityDefinitions:
|
|
||||||
Bearer:
|
|
||||||
description: Required authentication for all endpoints
|
|
||||||
name: Authorization
|
|
||||||
in: header
|
|
||||||
type: apiKey
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
# -*- coding: future_fstrings -*-
|
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2018 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from aiohttp import web
|
|
||||||
from mako.template import Template
|
|
||||||
import pkg_resources
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
|
|
||||||
from ...util import sign_token, verify_token
|
|
||||||
from ...user import User
|
|
||||||
from ...puppet import Puppet
|
|
||||||
from ..common import AuthAPI
|
|
||||||
|
|
||||||
|
|
||||||
class PublicBridgeWebsite(AuthAPI):
|
|
||||||
log = logging.getLogger("mau.web.public")
|
|
||||||
|
|
||||||
def __init__(self, loop):
|
|
||||||
super().__init__(loop)
|
|
||||||
self.secret_key = "".join(
|
|
||||||
random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
|
|
||||||
|
|
||||||
self.login = Template(
|
|
||||||
pkg_resources.resource_string("mautrix_telegram", "web/public/login.html.mako"))
|
|
||||||
|
|
||||||
self.mx_login = Template(
|
|
||||||
pkg_resources.resource_string("mautrix_telegram", "web/public/matrix-login.html.mako"))
|
|
||||||
|
|
||||||
self.app = web.Application(loop=loop)
|
|
||||||
self.app.router.add_route("GET", "/login", self.get_login)
|
|
||||||
self.app.router.add_route("POST", "/login", self.post_login)
|
|
||||||
self.app.router.add_route("GET", "/matrix-login", self.get_matrix_login)
|
|
||||||
self.app.router.add_route("POST", "/matrix-login", self.post_matrix_login)
|
|
||||||
self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_telegram",
|
|
||||||
"web/public/"))
|
|
||||||
|
|
||||||
def make_token(self, mxid, endpoint="/login", expires_in=900):
|
|
||||||
return sign_token(self.secret_key, {
|
|
||||||
"mxid": mxid,
|
|
||||||
"endpoint": endpoint,
|
|
||||||
"expiry": int(time.time()) + expires_in,
|
|
||||||
})
|
|
||||||
|
|
||||||
def verify_token(self, token, endpoint="/login"):
|
|
||||||
token = verify_token(self.secret_key, token)
|
|
||||||
if token and (token.get("expiry", 0) > int(time.time()) and
|
|
||||||
token.get("endpoint", None) == endpoint):
|
|
||||||
return token.get("mxid", None)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_login(self, request):
|
|
||||||
state = "bot_token" if request.rel_url.query.get("mode", "") == "bot" else "request"
|
|
||||||
|
|
||||||
mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/login")
|
|
||||||
if not mxid:
|
|
||||||
return self.get_login_response(status=401, state="invalid-token")
|
|
||||||
user = User.get_by_mxid(mxid, create=False) if mxid else None
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
return self.get_login_response(mxid=mxid, state=state)
|
|
||||||
elif not user.puppet_whitelisted:
|
|
||||||
return self.get_login_response(mxid=user.mxid, error="You are not whitelisted.",
|
|
||||||
status=403)
|
|
||||||
await user.ensure_started()
|
|
||||||
if not await user.is_logged_in():
|
|
||||||
return self.get_login_response(mxid=user.mxid, state=state)
|
|
||||||
|
|
||||||
return self.get_login_response(mxid=user.mxid, username=user.username)
|
|
||||||
|
|
||||||
async def get_matrix_login(self, request):
|
|
||||||
mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/matrix-login")
|
|
||||||
if not mxid:
|
|
||||||
return self.get_mx_login_response(status=401, state="invalid-token")
|
|
||||||
user = User.get_by_mxid(mxid, create=False) if mxid else None
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
return self.get_mx_login_response(mxid=mxid)
|
|
||||||
elif not user.puppet_whitelisted:
|
|
||||||
return self.get_mx_login_response(mxid=user.mxid, error="You are not whitelisted.",
|
|
||||||
status=403)
|
|
||||||
await user.ensure_started()
|
|
||||||
if not await user.is_logged_in():
|
|
||||||
return self.get_mx_login_response(mxid=user.mxid, status=403,
|
|
||||||
error="You are not logged in to Telegram.")
|
|
||||||
|
|
||||||
puppet = Puppet.get(user.tgid)
|
|
||||||
if puppet.is_real_user:
|
|
||||||
return self.get_mx_login_response(state="already-logged-in", status=409)
|
|
||||||
|
|
||||||
return self.get_mx_login_response(mxid=user.mxid)
|
|
||||||
|
|
||||||
def get_login_response(self, status=200, state="", username="", mxid="", message="", error="",
|
|
||||||
errcode=""):
|
|
||||||
return web.Response(status=status, content_type="text/html",
|
|
||||||
text=self.login.render(username=username, state=state, error=error,
|
|
||||||
message=message, mxid=mxid))
|
|
||||||
|
|
||||||
def get_mx_login_response(self, status=200, state="", username="", mxid="", message="",
|
|
||||||
error="", errcode=""):
|
|
||||||
return web.Response(status=status, content_type="text/html",
|
|
||||||
text=self.mx_login.render(username=username, state=state, error=error,
|
|
||||||
message=message, mxid=mxid))
|
|
||||||
|
|
||||||
async def post_matrix_login(self, request):
|
|
||||||
mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/matrix-login")
|
|
||||||
if not mxid:
|
|
||||||
return self.get_mx_login_response(status=401, state="invalid-token")
|
|
||||||
|
|
||||||
data = await request.post()
|
|
||||||
|
|
||||||
user = await User.get_by_mxid(mxid).ensure_started()
|
|
||||||
if not user.puppet_whitelisted:
|
|
||||||
return self.get_mx_login_response(mxid=user.mxid, error="You are not whitelisted.",
|
|
||||||
status=403)
|
|
||||||
elif not await user.is_logged_in():
|
|
||||||
return self.get_mx_login_response(mxid=user.mxid, status=403,
|
|
||||||
error="You are not logged in to Telegram.")
|
|
||||||
mode = data.get("mode", "access_token")
|
|
||||||
if mode == "password":
|
|
||||||
return await self.post_matrix_password(user, data["value"])
|
|
||||||
elif mode == "access_token":
|
|
||||||
return await self.post_matrix_token(user, data["value"])
|
|
||||||
return self.get_mx_login_response(mxid=user.mxid, status=400,
|
|
||||||
error="You must provide an access token or "
|
|
||||||
"password.")
|
|
||||||
|
|
||||||
async def post_login(self, request):
|
|
||||||
mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/login")
|
|
||||||
if not mxid:
|
|
||||||
return self.get_login_response(status=401, state="invalid-token")
|
|
||||||
|
|
||||||
data = await request.post()
|
|
||||||
|
|
||||||
user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True)
|
|
||||||
if not user.puppet_whitelisted:
|
|
||||||
return self.get_login_response(mxid=user.mxid, error="You are not whitelisted.",
|
|
||||||
status=403)
|
|
||||||
elif await user.is_logged_in():
|
|
||||||
return self.get_login_response(mxid=user.mxid, username=user.username)
|
|
||||||
|
|
||||||
await user.ensure_started(even_if_no_session=True)
|
|
||||||
|
|
||||||
if "phone" in data:
|
|
||||||
return await self.post_login_phone(user, data["phone"])
|
|
||||||
elif "bot_token" in data:
|
|
||||||
return await self.post_login_token(user, data["bot_token"])
|
|
||||||
elif "code" in data:
|
|
||||||
resp = await self.post_login_code(user, data["code"],
|
|
||||||
password_in_data="password" in data)
|
|
||||||
if resp or "password" not in data:
|
|
||||||
return resp
|
|
||||||
elif "password" not in data:
|
|
||||||
return self.get_login_response(error="No data given.", status=400)
|
|
||||||
|
|
||||||
if "password" in data:
|
|
||||||
return await self.post_login_password(user, data["password"])
|
|
||||||
return self.get_login_response(error="This should never happen.", status=500)
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
@@ -1,99 +0,0 @@
|
|||||||
/*
|
|
||||||
* mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
* Copyright (C) 2018 Tulir Asokan
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
form > div {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
form[data-status="request"] > div.status-request,
|
|
||||||
form[data-status="code"] > div.status-code,
|
|
||||||
form[data-status="password"] > div.status-password {
|
|
||||||
display: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
margin-top: 3rem;
|
|
||||||
max-width: 60rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error, .message {
|
|
||||||
border-radius: .25rem;
|
|
||||||
padding: .5rem 1rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
margin: .5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
border-color: #f5c6cb;
|
|
||||||
background-color: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
border-color: #c3e6cb;
|
|
||||||
background-color: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="checkbox"], [type="radio"] {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="checkbox"] + label, [type="radio"] + label {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 2.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="checkbox"] + label:before, [type="radio"] + label:before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0.4rem;
|
|
||||||
width: 1.8rem;
|
|
||||||
height: 1.8rem;
|
|
||||||
border: 0.1rem solid #d1d1d1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="radio"] + label:before, [type="radio"] + label:after {
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="checkbox"]:checked + label:after,
|
|
||||||
[type="radio"]:checked + label:after {
|
|
||||||
content: '';
|
|
||||||
width: 0.8rem;
|
|
||||||
height: 0.8rem;
|
|
||||||
background: #9b4dca;
|
|
||||||
position: absolute;
|
|
||||||
top: 0.9rem;
|
|
||||||
left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="radio"]:disabled + label:before, [type="checkbox"]:disabled + label:before {
|
|
||||||
background-color: #d1d1d1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="radio"]:disabled + label, [type="checkbox"]:disabled + label {
|
|
||||||
color: #d1d1d1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="radio"]:disabled:checked + label:after, [type="checkbox"]:disabled:checked + label:after {
|
|
||||||
background: #606c76;
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<!--
|
|
||||||
mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
Copyright (C) 2018 Tulir Asokan
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
-->
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Login - Mautrix-Telegram bridge</title>
|
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
|
||||||
<meta property="og:title" content="Login - Mautrix-Telegram bridge">
|
|
||||||
<meta property="og:description" content="A hybrid puppeting/relaybot Matrix-Telegram bridge">
|
|
||||||
<meta property="og:image" content="favicon.png">
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,700">
|
|
||||||
<link rel="stylesheet" href="//cdn.rawgit.com/necolas/normalize.css/master/normalize.css">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="//cdn.rawgit.com/milligram/milligram/master/dist/milligram.min.css">
|
|
||||||
<link rel="stylesheet" href="login.css"/>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function switchToBotLogin() {
|
|
||||||
const params = new URLSearchParams(location.search.slice(1))
|
|
||||||
params.set("mode", "bot")
|
|
||||||
location.search = "?" + params.toString()
|
|
||||||
console.log(location.search)
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
let params = new URLSearchParams(location.search.slice(1))
|
|
||||||
const token = params.get("token")
|
|
||||||
params = new URLSearchParams()
|
|
||||||
if (token) {
|
|
||||||
params.set("token", token)
|
|
||||||
}
|
|
||||||
location.replace(location.href.split("?")[0] + "?" + params.toString())
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="container">
|
|
||||||
% if username:
|
|
||||||
% if state == "logged-in":
|
|
||||||
<h1>Logged in successfully!</h1>
|
|
||||||
<p>
|
|
||||||
Logged in as @${username}.
|
|
||||||
You can now close this page.
|
|
||||||
You should be invited to Telegram portals on Matrix momentarily.
|
|
||||||
</p>
|
|
||||||
% elif state == "bot-logged-in":
|
|
||||||
<h1>Logged in successfully!</h1>
|
|
||||||
<p>
|
|
||||||
Logged in as @${username}.
|
|
||||||
You can now close this page.
|
|
||||||
You should be invited to Telegram portals on Matrix momentarily.
|
|
||||||
</p>
|
|
||||||
% else:
|
|
||||||
<h1>You're already logged in!</h1>
|
|
||||||
<p>
|
|
||||||
You're logged in as @${username}.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you want to log in with another account, log out using the <code>logout</code>
|
|
||||||
management command first.
|
|
||||||
</p>
|
|
||||||
% endif
|
|
||||||
% elif state == "invalid-token":
|
|
||||||
<h1>Invalid or expired token</h1>
|
|
||||||
<div class="error">Please ask the bridge bot for a new login link.</div>
|
|
||||||
% else:
|
|
||||||
<h1>Log in to Telegram</h1>
|
|
||||||
% if error:
|
|
||||||
<div class="error">${error}</div>
|
|
||||||
% endif
|
|
||||||
% if message:
|
|
||||||
<div class="message">${message}</div>
|
|
||||||
% endif
|
|
||||||
<form method="post">
|
|
||||||
<fieldset>
|
|
||||||
<label for="mxid">Matrix ID</label>
|
|
||||||
<input type="text" id="mxid" name="mxid" disabled value="${mxid}"/>
|
|
||||||
% if state == "request":
|
|
||||||
<label for="value">Phone number</label>
|
|
||||||
<input type="tel" id="value" name="phone" placeholder="Enter phone number"/>
|
|
||||||
<button type="submit">Request code</button>
|
|
||||||
<button class="button-clear" type="button" onclick="switchToBotLogin()">
|
|
||||||
Use bot token
|
|
||||||
</button>
|
|
||||||
% elif state == "bot_token":
|
|
||||||
<label for="value">Bot token</label>
|
|
||||||
<input type="text" id="value" name="bot_token" placeholder="Enter bot API token"/>
|
|
||||||
<button type="submit">Sign in</button>
|
|
||||||
% elif state == "code":
|
|
||||||
<label for="value">Phone code</label>
|
|
||||||
<input type="number" id="value" name="code" placeholder="Enter phone code"/>
|
|
||||||
<button type="submit">Sign in</button>
|
|
||||||
% elif state == "password":
|
|
||||||
<label for="value">Password</label>
|
|
||||||
<input type="password" id="value" name="password"
|
|
||||||
placeholder="Enter password"/>
|
|
||||||
<button type="submit">Sign in</button>
|
|
||||||
% endif
|
|
||||||
% if state != "request":
|
|
||||||
<div class="float-right">
|
|
||||||
<button class="button-clear" type="button" onclick="goBack()">
|
|
||||||
Go back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
% endif
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
% endif
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<!--
|
|
||||||
mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
Copyright (C) 2018 Tulir Asokan
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
-->
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Matrix login - Mautrix-Telegram bridge</title>
|
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
|
||||||
<meta property="og:title" content="Matrix login - Mautrix-Telegram bridge">
|
|
||||||
<meta property="og:description" content="A hybrid puppeting/relaybot Matrix-Telegram bridge">
|
|
||||||
<meta property="og:image" content="favicon.png">
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,700">
|
|
||||||
<link rel="stylesheet" href="//cdn.rawgit.com/necolas/normalize.css/master/normalize.css">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="//cdn.rawgit.com/milligram/milligram/master/dist/milligram.min.css">
|
|
||||||
<link rel="stylesheet" href="login.css"/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="container">
|
|
||||||
% if state == "logged-in":
|
|
||||||
<h1>Logged in successfully!</h1>
|
|
||||||
<p>
|
|
||||||
Logged in as ${mxid}.
|
|
||||||
You can now close this page.
|
|
||||||
</p>
|
|
||||||
% elif state == "already-logged-in":
|
|
||||||
<h1>You're already logged in!</h1>
|
|
||||||
<p>
|
|
||||||
If you want to log in with another account, log out using the
|
|
||||||
<code>logout-matrix</code> management command first.
|
|
||||||
</p>
|
|
||||||
% elif state == "invalid-token":
|
|
||||||
<h1>Invalid or expired token</h1>
|
|
||||||
<div class="error">Please ask the bridge bot for a new login link.</div>
|
|
||||||
% else:
|
|
||||||
<h1>Log in to Matrix</h1>
|
|
||||||
% if error:
|
|
||||||
<div class="error">${error}</div>
|
|
||||||
% endif
|
|
||||||
% if message:
|
|
||||||
<div class="message">${message}</div>
|
|
||||||
% endif
|
|
||||||
<form method="post">
|
|
||||||
<fieldset>
|
|
||||||
<label for="mxid">Matrix ID</label>
|
|
||||||
<input type="text" id="mxid" name="mxid" disabled value="${mxid}"/>
|
|
||||||
|
|
||||||
<input id="access_token" type="radio" name="mode" value="access_token" checked>
|
|
||||||
<label for="access_token">Access token</label><br>
|
|
||||||
<input id="password" type="radio" name="mode" value="password" disabled>
|
|
||||||
<label for="password">Password</label><br>
|
|
||||||
|
|
||||||
<label for="value">Value</label>
|
|
||||||
<input type="text" id="value" name="value"
|
|
||||||
placeholder="Enter Matrix access token or password"/>
|
|
||||||
|
|
||||||
<button type="submit">Sign in</button>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
% endif
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
lxml
|
|
||||||
cryptg
|
|
||||||
Pillow
|
|
||||||
moviepy
|
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ bridgev2.BackfillingNetworkAPI = (*TelegramClient)(nil)
|
||||||
|
_ bridgev2.BackfillingNetworkAPIWithLimits = (*TelegramClient)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// getTakeoutID blocks until the takeout ID is available.
|
||||||
|
func (tc *TelegramClient) getTakeoutID(ctx context.Context) (takeoutID int64, err error) {
|
||||||
|
// Always stop the takeout timeout timer
|
||||||
|
if tc.stopTakeoutTimer != nil {
|
||||||
|
tc.stopTakeoutTimer.Stop()
|
||||||
|
}
|
||||||
|
log := zerolog.Ctx(ctx).With().Str("function", "getTakeoutID").Logger()
|
||||||
|
|
||||||
|
if tc.metadata.TakeoutID != 0 {
|
||||||
|
// Resume fetching dialogs using takeout and enqueueing them for
|
||||||
|
// backfill.
|
||||||
|
go tc.takeoutDialogsOnce.Do(func() {
|
||||||
|
if err = tc.syncChats(ctx, takeoutID, false, false); err != nil {
|
||||||
|
log.Err(err).Msg("Failed to takeout dialogs")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return tc.metadata.TakeoutID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.stopTakeoutTimer = time.AfterFunc(max(time.Hour, time.Duration(tc.main.Bridge.Config.Backfill.Queue.BatchDelay*2)), sync.OnceFunc(func() { tc.stopTakeout(ctx) }))
|
||||||
|
|
||||||
|
for {
|
||||||
|
tc.takeoutAccepted.Clear()
|
||||||
|
|
||||||
|
accountTakeout, err := tc.client.API().AccountInitTakeoutSession(ctx, &tg.AccountInitTakeoutSessionRequest{
|
||||||
|
MessageUsers: true,
|
||||||
|
MessageChats: true,
|
||||||
|
MessageMegagroups: true,
|
||||||
|
MessageChannels: true,
|
||||||
|
Files: true,
|
||||||
|
FileMaxSize: min(tc.main.maxFileSize, 2000*1024*1024),
|
||||||
|
})
|
||||||
|
if rpcErr, ok := tgerr.As(err); ok && rpcErr.IsOneOf(tg.ErrTakeoutInitDelay) {
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Int("delay", rpcErr.Argument).
|
||||||
|
Msg("Takeout requested, will wait for retry request or delay")
|
||||||
|
tc.takeoutAccepted.WaitTimeout(time.Duration(rpcErr.Argument) * time.Second)
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all dialogs using takeout and enqueue them for backfill.
|
||||||
|
go tc.takeoutDialogsOnce.Do(func() {
|
||||||
|
if err = tc.syncChats(ctx, takeoutID, false, false); err != nil {
|
||||||
|
log.Err(err).Msg("Failed to takeout dialogs")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tc.metadata.TakeoutID = accountTakeout.ID
|
||||||
|
return accountTakeout.ID, tc.userLogin.Save(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) stopTakeout(ctx context.Context) error {
|
||||||
|
tc.takeoutLock.Lock()
|
||||||
|
defer tc.takeoutLock.Unlock()
|
||||||
|
|
||||||
|
_, err := tc.client.API().AccountFinishTakeoutSession(ctx, &tg.AccountFinishTakeoutSessionRequest{Success: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tc.metadata.TakeoutID = 0
|
||||||
|
return tc.userLogin.Save(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) FetchMessages(ctx context.Context, fetchParams bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
|
||||||
|
if tc.metadata.IsBot {
|
||||||
|
return nil, fmt.Errorf("bots cannot backfill messages")
|
||||||
|
}
|
||||||
|
log := zerolog.Ctx(ctx).With().Str("method", "FetchMessages").Logger()
|
||||||
|
ctx = log.WithContext(ctx)
|
||||||
|
|
||||||
|
var takeoutID int64
|
||||||
|
var err error
|
||||||
|
if (tc.main.Config.Takeout.ForwardBackfill && fetchParams.Forward) || (tc.main.Config.Takeout.BackwardBackfill && !fetchParams.Forward) {
|
||||||
|
tc.takeoutLock.Lock()
|
||||||
|
defer tc.takeoutLock.Unlock()
|
||||||
|
takeoutID, err = tc.getTakeoutID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if takeoutID != 0 {
|
||||||
|
defer func() {
|
||||||
|
if tc.stopTakeoutTimer == nil {
|
||||||
|
tc.stopTakeoutTimer = time.AfterFunc(max(time.Hour, time.Duration(tc.main.Bridge.Config.Backfill.Queue.BatchDelay*2)), sync.OnceFunc(func() { tc.stopTakeout(ctx) }))
|
||||||
|
} else {
|
||||||
|
tc.stopTakeoutTimer.Reset(max(time.Hour, time.Duration(tc.main.Bridge.Config.Backfill.Queue.BatchDelay*2)))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peer, topicID, err := tc.inputPeerForPortalID(ctx, fetchParams.Portal.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var minID, offsetID int
|
||||||
|
if fetchParams.AnchorMessage != nil {
|
||||||
|
if fetchParams.Forward {
|
||||||
|
_, minID, err = ids.ParseMessageID(fetchParams.AnchorMessage.ID)
|
||||||
|
} else {
|
||||||
|
_, offsetID, err = ids.ParseMessageID(fetchParams.AnchorMessage.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fetchParams.Portal.Metadata.(*PortalMetadata).IsForumGeneral {
|
||||||
|
topicID = 1
|
||||||
|
}
|
||||||
|
if topicID == ids.TopicIDSpaceRoom {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
limit := fetchParams.Count
|
||||||
|
const chunkLimit = 100
|
||||||
|
makeReq := func() bin.Object {
|
||||||
|
if topicID > 0 {
|
||||||
|
return &tg.MessagesGetRepliesRequest{
|
||||||
|
Peer: peer,
|
||||||
|
MsgID: topicID,
|
||||||
|
Limit: min(limit, chunkLimit),
|
||||||
|
MinID: minID,
|
||||||
|
OffsetID: offsetID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &tg.MessagesGetHistoryRequest{
|
||||||
|
Peer: peer,
|
||||||
|
Limit: min(limit, chunkLimit),
|
||||||
|
MinID: minID,
|
||||||
|
OffsetID: offsetID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var messages []tg.MessageClass
|
||||||
|
requestCount := 0
|
||||||
|
for limit > 0 {
|
||||||
|
requestCount++
|
||||||
|
req := makeReq()
|
||||||
|
if takeoutID != 0 {
|
||||||
|
req = &tg.InvokeWithTakeoutRequest{TakeoutID: takeoutID, Query: req}
|
||||||
|
}
|
||||||
|
log.Info().Any("req", req).Msg("Fetching messages")
|
||||||
|
resp, err := APICallWithUpdates(ctx, tc, func() (tg.ModifiedMessagesMessages, error) {
|
||||||
|
var box tg.MessagesMessagesBox
|
||||||
|
retry := true
|
||||||
|
attempts := 0
|
||||||
|
var err error
|
||||||
|
for retry && attempts < 5 {
|
||||||
|
retry, err = tgerr.FloodWait(ctx, tc.client.Invoke(ctx, req, &box))
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgs, ok := box.Messages.(tg.ModifiedMessagesMessages)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported messages type %T", box.Messages)
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if tgerr.Is(err, tg.ErrTakeoutInvalid) {
|
||||||
|
tc.metadata.TakeoutID = 0
|
||||||
|
err := tc.userLogin.Save(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to save user login after clearing takeout ID")
|
||||||
|
} else {
|
||||||
|
log.Debug().Msg("Cleared invalid takeout ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
newMessages := resp.GetMessages()
|
||||||
|
if messages == nil {
|
||||||
|
messages = newMessages
|
||||||
|
} else {
|
||||||
|
messages = append(messages, resp.GetMessages()...)
|
||||||
|
}
|
||||||
|
if len(newMessages) < chunkLimit || !fetchParams.Forward {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
limit -= len(newMessages)
|
||||||
|
offsetID = newMessages[len(newMessages)-1].GetID()
|
||||||
|
if takeoutID == 0 {
|
||||||
|
waitTime := time.Duration(min(requestCount*2, 15)) * time.Second
|
||||||
|
log.Debug().
|
||||||
|
Dur("wait_time", waitTime).
|
||||||
|
Msg("Not using takeout, waiting before requesting another batch of messages")
|
||||||
|
select {
|
||||||
|
case <-time.After(waitTime):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
portal := fetchParams.Portal
|
||||||
|
|
||||||
|
// If the first message is the last read message, mark the chat as read
|
||||||
|
// during backfill.
|
||||||
|
markRead := fetchParams.Forward &&
|
||||||
|
len(messages) > 0 &&
|
||||||
|
portal.Metadata.(*PortalMetadata).ReadUpTo == messages[0].GetID()
|
||||||
|
|
||||||
|
var cursor networkid.PaginationCursor
|
||||||
|
if len(messages) > 0 {
|
||||||
|
cursor = ids.MakePaginationCursorID(messages[len(messages)-1].GetID())
|
||||||
|
}
|
||||||
|
|
||||||
|
var stopAt int
|
||||||
|
if fetchParams.AnchorMessage != nil {
|
||||||
|
_, stopAt, err = ids.ParseMessageID(fetchParams.AnchorMessage.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log = log.With().Int("stop_at", stopAt).Logger()
|
||||||
|
}
|
||||||
|
|
||||||
|
var backfillMessages []*bridgev2.BackfillMessage
|
||||||
|
for _, msg := range messages {
|
||||||
|
log := log.With().Int("message_id", msg.GetID()).Logger()
|
||||||
|
if stopAt > 0 {
|
||||||
|
if fetchParams.Forward && msg.GetID() <= stopAt {
|
||||||
|
// If we are doing forward backfill and we get to the anchor
|
||||||
|
// message, don't convert any more messages.
|
||||||
|
log.Debug().Msg("stopping at anchor message")
|
||||||
|
break
|
||||||
|
} else if !fetchParams.Forward && msg.GetID() >= stopAt {
|
||||||
|
// If we are doing backwards backfill and we get a message more
|
||||||
|
// recent than the anchor message, skip it.
|
||||||
|
log.Debug().Msg("skipping message past anchor message")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message, ok := msg.(*tg.Message)
|
||||||
|
if !ok {
|
||||||
|
log.Debug().Str("type", msg.TypeName()).Msg("skipping backfilling unsupported message type")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := tc.getEventSender(message, !portal.Metadata.(*PortalMetadata).IsSuperGroup)
|
||||||
|
intent, ok := portal.GetIntentFor(ctx, sender, tc.userLogin, bridgev2.RemoteEventBackfill)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
converted, err := tc.convertToMatrix(ctx, portal, intent, message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
backfillMessage := bridgev2.BackfillMessage{
|
||||||
|
ConvertedMessage: converted,
|
||||||
|
Sender: sender,
|
||||||
|
ID: ids.GetMessageIDFromMessage(message),
|
||||||
|
Timestamp: time.Unix(int64(message.Date), 0),
|
||||||
|
StreamOrder: int64(message.GetID()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if reactions, ok := message.GetReactions(); ok {
|
||||||
|
reactionsList, _, customEmojis, err := tc.computeReactionsList(ctx, message.PeerID, message.ID, reactions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reaction := range reactionsList {
|
||||||
|
peer, ok := reaction.PeerID.(*tg.PeerUser)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown peer type %T", reaction.PeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
emojiID, emoji, err := computeEmojiAndID(reaction.Reaction, customEmojis)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to compute emoji and ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backfillMessage.Reactions = append(backfillMessage.Reactions, &bridgev2.BackfillReaction{
|
||||||
|
Timestamp: time.Unix(int64(reaction.Date), 0),
|
||||||
|
Sender: tc.senderForUserID(peer.UserID),
|
||||||
|
EmojiID: emojiID,
|
||||||
|
Emoji: emoji,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backfillMessages = append(backfillMessages, &backfillMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// They are returned with most recent message first, so reverse the order.
|
||||||
|
slices.Reverse(backfillMessages)
|
||||||
|
|
||||||
|
return &bridgev2.FetchMessagesResponse{
|
||||||
|
Messages: backfillMessages,
|
||||||
|
Cursor: cursor,
|
||||||
|
HasMore: len(backfillMessages) > 0,
|
||||||
|
Forward: fetchParams.Forward,
|
||||||
|
MarkRead: markRead,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) GetBackfillMaxBatchCount(ctx context.Context, portal *bridgev2.Portal, task *database.BackfillTask) int {
|
||||||
|
log := zerolog.Ctx(ctx).With().
|
||||||
|
Str("method", "GetBackfillMaxBatchCount").
|
||||||
|
Logger()
|
||||||
|
peerType, _, topicID, err := ids.ParsePortalID(portal.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to parse portal ID")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch peerType {
|
||||||
|
case ids.PeerTypeUser:
|
||||||
|
return tc.main.Bridge.Config.Backfill.Queue.GetOverride("user")
|
||||||
|
case ids.PeerTypeChat:
|
||||||
|
return tc.main.Bridge.Config.Backfill.Queue.GetOverride("normal_group")
|
||||||
|
case ids.PeerTypeChannel:
|
||||||
|
if topicID == ids.TopicIDSpaceRoom {
|
||||||
|
return 0
|
||||||
|
} else if topicID > 0 {
|
||||||
|
return tc.main.Bridge.Config.Backfill.Queue.GetOverride("topic", "supergroup")
|
||||||
|
} else if portal.Metadata.(*PortalMetadata).IsSuperGroup {
|
||||||
|
return tc.main.Bridge.Config.Backfill.Queue.GetOverride("supergroup")
|
||||||
|
} else {
|
||||||
|
return tc.main.Bridge.Config.Backfill.Queue.GetOverride("channel")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Error().Str("peer_type", string(peerType)).Msg("unknown peer type")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mau.fi/util/jsontime"
|
||||||
|
"go.mau.fi/util/ptr"
|
||||||
|
"go.mau.fi/util/variationselector"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
|
||||||
|
return &bridgev2.NetworkGeneralCapabilities{
|
||||||
|
DisappearingMessages: true,
|
||||||
|
Provisioning: bridgev2.ProvisioningCapabilities{
|
||||||
|
ImagePackImport: true,
|
||||||
|
ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{
|
||||||
|
CreateDM: true,
|
||||||
|
LookupPhone: true,
|
||||||
|
LookupUsername: true,
|
||||||
|
ContactList: true,
|
||||||
|
Search: true,
|
||||||
|
},
|
||||||
|
GroupCreation: map[string]bridgev2.GroupTypeCapabilities{
|
||||||
|
"group": {
|
||||||
|
TypeDescription: "a normal group",
|
||||||
|
Name: bridgev2.GroupFieldCapability{Allowed: true, Required: true, MaxLength: 255},
|
||||||
|
Participants: bridgev2.GroupFieldCapability{Allowed: true, Required: true, MinLength: 1, MaxLength: 200},
|
||||||
|
// TODO implement
|
||||||
|
//Disappear: bridgev2.GroupFieldCapability{Allowed: true},
|
||||||
|
},
|
||||||
|
// TODO
|
||||||
|
//"channel": {},
|
||||||
|
//"supergroup": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) GetBridgeInfoVersion() (info, capabilities int) {
|
||||||
|
return 1, 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO get these from getConfig instead of hardcoding?
|
||||||
|
|
||||||
|
const MaxTextLength = 4096
|
||||||
|
const MaxCaptionLength = 1024
|
||||||
|
const MaxFileSize = 2 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
var formattingCaps = event.FormattingFeatureMap{
|
||||||
|
event.FmtBold: event.CapLevelFullySupported,
|
||||||
|
event.FmtItalic: event.CapLevelFullySupported,
|
||||||
|
event.FmtUnderline: event.CapLevelFullySupported,
|
||||||
|
event.FmtStrikethrough: event.CapLevelFullySupported,
|
||||||
|
event.FmtInlineCode: event.CapLevelFullySupported,
|
||||||
|
event.FmtCodeBlock: event.CapLevelFullySupported,
|
||||||
|
event.FmtSyntaxHighlighting: event.CapLevelFullySupported,
|
||||||
|
event.FmtBlockquote: event.CapLevelFullySupported,
|
||||||
|
event.FmtInlineLink: event.CapLevelFullySupported,
|
||||||
|
event.FmtUserLink: event.CapLevelFullySupported,
|
||||||
|
// TODO support room links and event links (convert to appropriate t.me links)
|
||||||
|
event.FmtUnorderedList: event.CapLevelPartialSupport,
|
||||||
|
event.FmtOrderedList: event.CapLevelPartialSupport,
|
||||||
|
event.FmtListStart: event.CapLevelPartialSupport,
|
||||||
|
event.FmtListJumpValue: event.CapLevelDropped,
|
||||||
|
// TODO support custom emojis in messages
|
||||||
|
event.FmtCustomEmoji: event.CapLevelDropped,
|
||||||
|
event.FmtSpoiler: event.CapLevelFullySupported,
|
||||||
|
event.FmtSpoilerReason: event.CapLevelDropped,
|
||||||
|
event.FmtHeaders: event.CapLevelPartialSupport,
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileCaps = event.FileFeatureMap{
|
||||||
|
event.MsgImage: {
|
||||||
|
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||||
|
"image/jpeg": event.CapLevelFullySupported,
|
||||||
|
"image/webp": event.CapLevelPartialSupport,
|
||||||
|
"image/png": event.CapLevelPartialSupport,
|
||||||
|
"image/gif": event.CapLevelPartialSupport,
|
||||||
|
},
|
||||||
|
Caption: event.CapLevelFullySupported,
|
||||||
|
MaxCaptionLength: MaxCaptionLength,
|
||||||
|
MaxSize: 10 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
event.MsgVideo: {
|
||||||
|
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||||
|
"video/mp4": event.CapLevelFullySupported,
|
||||||
|
},
|
||||||
|
Caption: event.CapLevelFullySupported,
|
||||||
|
MaxCaptionLength: MaxCaptionLength,
|
||||||
|
MaxSize: MaxFileSize,
|
||||||
|
},
|
||||||
|
event.MsgAudio: {
|
||||||
|
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||||
|
"audio/mpeg": event.CapLevelFullySupported,
|
||||||
|
"audio/mp4": event.CapLevelFullySupported,
|
||||||
|
// TODO some other formats are probably supported too
|
||||||
|
},
|
||||||
|
Caption: event.CapLevelFullySupported,
|
||||||
|
MaxCaptionLength: MaxCaptionLength,
|
||||||
|
MaxSize: MaxFileSize,
|
||||||
|
},
|
||||||
|
event.MsgFile: {
|
||||||
|
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||||
|
"*/*": event.CapLevelFullySupported,
|
||||||
|
},
|
||||||
|
Caption: event.CapLevelFullySupported,
|
||||||
|
MaxCaptionLength: MaxCaptionLength,
|
||||||
|
MaxSize: MaxFileSize,
|
||||||
|
},
|
||||||
|
event.CapMsgGIF: {
|
||||||
|
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||||
|
"image/gif": event.CapLevelPartialSupport,
|
||||||
|
"video/mp4": event.CapLevelFullySupported,
|
||||||
|
},
|
||||||
|
Caption: event.CapLevelFullySupported,
|
||||||
|
MaxCaptionLength: MaxCaptionLength,
|
||||||
|
MaxSize: MaxFileSize,
|
||||||
|
},
|
||||||
|
event.CapMsgSticker: {
|
||||||
|
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||||
|
"image/webp": event.CapLevelFullySupported,
|
||||||
|
// These are converted to webp
|
||||||
|
"image/jpeg": event.CapLevelPartialSupport,
|
||||||
|
"image/png": event.CapLevelPartialSupport,
|
||||||
|
// These will only go through if they're from an imported Telegram pack
|
||||||
|
"video/lottie+json": event.CapLevelPartialSupport,
|
||||||
|
"video/webm": event.CapLevelPartialSupport,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event.CapMsgVoice: {
|
||||||
|
MimeTypes: map[string]event.CapabilitySupportLevel{
|
||||||
|
"audio/ogg": event.CapLevelFullySupported,
|
||||||
|
"audio/mpeg": event.CapLevelFullySupported,
|
||||||
|
"audio/mp4": event.CapLevelFullySupported,
|
||||||
|
},
|
||||||
|
Caption: event.CapLevelFullySupported,
|
||||||
|
MaxCaptionLength: MaxCaptionLength,
|
||||||
|
MaxSize: MaxFileSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var premiumFileCaps event.FileFeatureMap
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
premiumFileCaps = make(event.FileFeatureMap, len(fileCaps))
|
||||||
|
for k, v := range fileCaps {
|
||||||
|
cloned := ptr.Clone(v)
|
||||||
|
if k == event.MsgFile || k == event.MsgVideo || k == event.MsgAudio {
|
||||||
|
cloned.MaxSize *= 2
|
||||||
|
}
|
||||||
|
cloned.MaxCaptionLength *= 2
|
||||||
|
premiumFileCaps[k] = cloned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashEmojiList(emojis []string) string {
|
||||||
|
hasher := sha256.New()
|
||||||
|
for _, emoji := range emojis {
|
||||||
|
hasher.Write([]byte(emoji))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil))[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTimerList() []jsontime.Milliseconds {
|
||||||
|
const day = 24 * time.Hour
|
||||||
|
const week = 7 * day
|
||||||
|
const month = 31 * day
|
||||||
|
const year = 365 * day
|
||||||
|
return []jsontime.Milliseconds{
|
||||||
|
jsontime.MS(1 * day),
|
||||||
|
jsontime.MS(2 * day),
|
||||||
|
jsontime.MS(3 * day),
|
||||||
|
jsontime.MS(4 * day),
|
||||||
|
jsontime.MS(5 * day),
|
||||||
|
jsontime.MS(6 * day),
|
||||||
|
jsontime.MS(1 * week),
|
||||||
|
jsontime.MS(2 * week),
|
||||||
|
jsontime.MS(3 * week),
|
||||||
|
jsontime.MS(1 * month),
|
||||||
|
jsontime.MS(2 * month),
|
||||||
|
jsontime.MS(3 * month),
|
||||||
|
jsontime.MS(4 * month),
|
||||||
|
jsontime.MS(5 * month),
|
||||||
|
jsontime.MS(6 * month),
|
||||||
|
jsontime.MS(1 * year),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var telegramTimers = makeTimerList()
|
||||||
|
|
||||||
|
func (tc *TelegramClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
|
||||||
|
baseID := "fi.mau.telegram.capabilities.2025_11_24"
|
||||||
|
feat := &event.RoomFeatures{
|
||||||
|
Formatting: formattingCaps,
|
||||||
|
File: fileCaps,
|
||||||
|
MaxTextLength: MaxTextLength,
|
||||||
|
LocationMessage: event.CapLevelFullySupported,
|
||||||
|
Reply: event.CapLevelFullySupported,
|
||||||
|
Edit: event.CapLevelFullySupported,
|
||||||
|
Delete: event.CapLevelFullySupported,
|
||||||
|
Reaction: event.CapLevelFullySupported,
|
||||||
|
ReactionCount: 1,
|
||||||
|
ReadReceipts: true,
|
||||||
|
TypingNotifications: true,
|
||||||
|
|
||||||
|
DisappearingTimer: &event.DisappearingTimerCapability{
|
||||||
|
Types: []event.DisappearingType{event.DisappearingTypeAfterSend},
|
||||||
|
Timers: telegramTimers,
|
||||||
|
},
|
||||||
|
State: event.StateFeatureMap{
|
||||||
|
event.StateRoomName.Type: {Level: event.CapLevelFullySupported},
|
||||||
|
event.StateRoomAvatar.Type: {Level: event.CapLevelFullySupported},
|
||||||
|
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// TODO non-admins can only edit messages within 48 hours
|
||||||
|
|
||||||
|
reactions := portal.Metadata.(*PortalMetadata).AllowedReactions
|
||||||
|
if reactions == nil {
|
||||||
|
baseID += "+reactions_any"
|
||||||
|
feat.AllowedReactions, feat.CustomEmojiReactions = tc.getAvailableReactionsForCapability(ctx)
|
||||||
|
if len(feat.AllowedReactions) > 0 {
|
||||||
|
baseID += "+any_list_" + hashEmojiList(feat.AllowedReactions)
|
||||||
|
}
|
||||||
|
} else if len(reactions) == 0 {
|
||||||
|
baseID += "+reactions_none"
|
||||||
|
feat.Reaction = event.CapLevelRejected
|
||||||
|
} else {
|
||||||
|
baseID += "+reactions_" + hashEmojiList(reactions)
|
||||||
|
feat.AllowedReactions = reactions
|
||||||
|
}
|
||||||
|
for i, react := range feat.AllowedReactions {
|
||||||
|
feat.AllowedReactions[i] = variationselector.Add(react)
|
||||||
|
}
|
||||||
|
if tc.isPremiumCache.Load() {
|
||||||
|
baseID += "+premium"
|
||||||
|
feat.File = premiumFileCaps
|
||||||
|
feat.ReactionCount = 3
|
||||||
|
}
|
||||||
|
portalMetadata := portal.Metadata.(*PortalMetadata)
|
||||||
|
peerType, _, topicID, _ := ids.ParsePortalID(portal.ID)
|
||||||
|
if topicID > 0 {
|
||||||
|
baseID += "+topic"
|
||||||
|
// TODO do topics have other changes?
|
||||||
|
delete(feat.State, event.StateRoomAvatar.Type)
|
||||||
|
delete(feat.State, event.StateBeeperDisappearingTimer.Type)
|
||||||
|
feat.DisappearingTimer = nil
|
||||||
|
} else if topicID == ids.TopicIDSpaceRoom {
|
||||||
|
baseID += "+spaceroom"
|
||||||
|
feat = &event.RoomFeatures{}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch portal.RoomType {
|
||||||
|
case database.RoomTypeDM:
|
||||||
|
baseID += "+dm"
|
||||||
|
feat.DeleteChat = true
|
||||||
|
feat.DeleteChatForEveryone = true
|
||||||
|
feat.State = event.StateFeatureMap{
|
||||||
|
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Group creators can delete the chat for everyone, unless it's a large channel
|
||||||
|
if peerType == ids.PeerTypeChat || portalMetadata.ParticipantsCount < 1000 || topicID > 0 {
|
||||||
|
baseID += "+deletablegroup"
|
||||||
|
feat.DeleteChatForEveryone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feat.ID = baseID
|
||||||
|
return feat
|
||||||
|
}
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"go.mau.fi/util/ptr"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mutedPowerLevel = ptr.Ptr(-1)
|
||||||
|
anyonePowerLevel = ptr.Ptr(0)
|
||||||
|
modPowerLevel = ptr.Ptr(50)
|
||||||
|
superadminPowerLevel = ptr.Ptr(75)
|
||||||
|
creatorPowerLevel = ptr.Ptr(95)
|
||||||
|
nobodyPowerLevel = ptr.Ptr(99)
|
||||||
|
|
||||||
|
otherPowerLevel = ptr.Ptr(40)
|
||||||
|
anonymousPowerLevel = ptr.Ptr(41)
|
||||||
|
postMessagesPowerLevel = ptr.Ptr(42)
|
||||||
|
editMessagesPowerLevel = ptr.Ptr(43)
|
||||||
|
deleteMessagesPowerLevel = ptr.Ptr(44)
|
||||||
|
postStoriesPowerLevel = ptr.Ptr(45)
|
||||||
|
editStoriesPowerLevel = ptr.Ptr(46)
|
||||||
|
deleteStoriesPowerLevel = ptr.Ptr(47)
|
||||||
|
changeInfoPowerLevel = ptr.Ptr(50)
|
||||||
|
inviteUsersPowerLevel = ptr.Ptr(51)
|
||||||
|
manageCallPowerLevel = ptr.Ptr(52)
|
||||||
|
pinMessagesPowerLevel = ptr.Ptr(53)
|
||||||
|
manageTopicsPowerLevel = ptr.Ptr(54)
|
||||||
|
banUsersPowerLevel = ptr.Ptr(55)
|
||||||
|
addAdminsPowerLevel = ptr.Ptr(60)
|
||||||
|
)
|
||||||
|
|
||||||
|
func adminRightsToPowerLevel(rights tg.ChatAdminRights) *int {
|
||||||
|
if rights.AddAdmins {
|
||||||
|
return addAdminsPowerLevel
|
||||||
|
} else if rights.BanUsers {
|
||||||
|
return banUsersPowerLevel
|
||||||
|
} else if rights.ManageTopics {
|
||||||
|
return manageTopicsPowerLevel
|
||||||
|
} else if rights.PinMessages {
|
||||||
|
return pinMessagesPowerLevel
|
||||||
|
} else if rights.ManageCall {
|
||||||
|
return manageCallPowerLevel
|
||||||
|
} else if rights.InviteUsers {
|
||||||
|
return inviteUsersPowerLevel
|
||||||
|
} else if rights.ChangeInfo {
|
||||||
|
return changeInfoPowerLevel
|
||||||
|
} else if rights.DeleteStories {
|
||||||
|
return deleteStoriesPowerLevel
|
||||||
|
} else if rights.EditStories {
|
||||||
|
return editStoriesPowerLevel
|
||||||
|
} else if rights.PostStories {
|
||||||
|
return postStoriesPowerLevel
|
||||||
|
} else if rights.DeleteMessages {
|
||||||
|
return deleteMessagesPowerLevel
|
||||||
|
} else if rights.EditMessages {
|
||||||
|
return editMessagesPowerLevel
|
||||||
|
} else if rights.PostMessages {
|
||||||
|
return postMessagesPowerLevel
|
||||||
|
} else if rights.Anonymous {
|
||||||
|
return anonymousPowerLevel
|
||||||
|
}
|
||||||
|
return otherPowerLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) getDMChatInfo(ctx context.Context, userID int64) (*bridgev2.ChatInfo, error) {
|
||||||
|
ghost, err := tc.main.Bridge.GetGhostByID(ctx, ids.MakeUserID(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chatInfo := bridgev2.ChatInfo{
|
||||||
|
Type: ptr.Ptr(database.RoomTypeDM),
|
||||||
|
Members: &bridgev2.ChatMemberList{
|
||||||
|
IsFull: true,
|
||||||
|
MemberMap: map[networkid.UserID]bridgev2.ChatMember{},
|
||||||
|
PowerLevels: tc.getDMPowerLevels(ghost),
|
||||||
|
},
|
||||||
|
CanBackfill: !tc.metadata.IsBot,
|
||||||
|
ExtraUpdates: updatePortalLastSyncAt,
|
||||||
|
}
|
||||||
|
chatInfo.Members.MemberMap.Add(bridgev2.ChatMember{EventSender: tc.mySender()})
|
||||||
|
chatInfo.Members.MemberMap.Add(bridgev2.ChatMember{EventSender: tc.senderForUserID(userID)})
|
||||||
|
if userID == tc.telegramUserID {
|
||||||
|
chatInfo.Avatar = &bridgev2.Avatar{
|
||||||
|
ID: networkid.AvatarID(tc.main.Config.SavedMessagesAvatar),
|
||||||
|
Remove: len(tc.main.Config.SavedMessagesAvatar) == 0,
|
||||||
|
MXC: tc.main.Config.SavedMessagesAvatar,
|
||||||
|
Hash: sha256.Sum256([]byte(tc.main.Config.SavedMessagesAvatar)),
|
||||||
|
}
|
||||||
|
chatInfo.Name = ptr.Ptr("Telegram Saved Messages")
|
||||||
|
chatInfo.Topic = ptr.Ptr("Your Telegram cloud storage chat")
|
||||||
|
}
|
||||||
|
return &chatInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBroadcastChannel(chat tg.ChatClass) bool {
|
||||||
|
switch c := chat.(type) {
|
||||||
|
case *tg.Channel:
|
||||||
|
return c.Broadcast
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memberFetchMeta struct {
|
||||||
|
Input *tg.InputChannel
|
||||||
|
IsBroadcast bool
|
||||||
|
ParticipantsHidden bool
|
||||||
|
IsForum bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) wrapChatInfo(portalID networkid.PortalID, rawChat tg.ChatClass) (*bridgev2.ChatInfo, *memberFetchMeta, error) {
|
||||||
|
info := bridgev2.ChatInfo{
|
||||||
|
Type: ptr.Ptr(database.RoomTypeDefault),
|
||||||
|
CanBackfill: !tc.metadata.IsBot,
|
||||||
|
Members: &bridgev2.ChatMemberList{
|
||||||
|
ExcludeChangesFromTimeline: true,
|
||||||
|
MemberMap: bridgev2.ChatMemberMap{},
|
||||||
|
},
|
||||||
|
ExcludeChangesFromTimeline: true,
|
||||||
|
}
|
||||||
|
var mfm memberFetchMeta
|
||||||
|
var isMegagroup, isForumGeneral, left bool
|
||||||
|
var avatarErr error
|
||||||
|
var ownPL *int
|
||||||
|
switch chat := rawChat.(type) {
|
||||||
|
case *tg.Chat:
|
||||||
|
info.Name = &chat.Title
|
||||||
|
info.Members.TotalMemberCount = chat.ParticipantsCount
|
||||||
|
info.Avatar, avatarErr = tc.convertChatPhoto(chat.AsInputPeer(), chat.Photo)
|
||||||
|
info.Members.PowerLevels = tc.getPowerLevelOverridesFromBannedRights(chat, chat.DefaultBannedRights)
|
||||||
|
left = chat.Left
|
||||||
|
if chat.Creator {
|
||||||
|
ownPL = creatorPowerLevel
|
||||||
|
} else if rights, isAdmin := chat.GetAdminRights(); isAdmin {
|
||||||
|
ownPL = adminRightsToPowerLevel(rights)
|
||||||
|
} else {
|
||||||
|
ownPL = anyonePowerLevel
|
||||||
|
}
|
||||||
|
case *tg.Channel:
|
||||||
|
mfm.Input = chat.AsInput()
|
||||||
|
mfm.IsBroadcast = chat.Broadcast
|
||||||
|
info.Name = &chat.Title
|
||||||
|
info.Members.TotalMemberCount = chat.ParticipantsCount
|
||||||
|
isMegagroup = chat.Megagroup
|
||||||
|
info.Avatar, avatarErr = tc.convertChatPhoto(chat.AsInputPeer(), chat.Photo)
|
||||||
|
info.Members.PowerLevels = tc.getPowerLevelOverridesFromBannedRights(chat, chat.DefaultBannedRights)
|
||||||
|
if chat.Creator {
|
||||||
|
ownPL = creatorPowerLevel
|
||||||
|
} else if rights, isAdmin := chat.GetAdminRights(); isAdmin {
|
||||||
|
ownPL = adminRightsToPowerLevel(rights)
|
||||||
|
} else {
|
||||||
|
ownPL = anyonePowerLevel
|
||||||
|
}
|
||||||
|
_, _, topicID, _ := ids.ParsePortalID(portalID)
|
||||||
|
if chat.Forum {
|
||||||
|
if topicID == ids.TopicIDSpaceRoom {
|
||||||
|
info.Type = ptr.Ptr(database.RoomTypeSpace)
|
||||||
|
} else if topicID == 0 {
|
||||||
|
isForumGeneral = true
|
||||||
|
info.Name = ptr.Ptr("#General - " + *info.Name)
|
||||||
|
}
|
||||||
|
if topicID != ids.TopicIDSpaceRoom {
|
||||||
|
info.ParentID = ptr.Ptr(ids.MakeForumParentPortalID(chat.ID))
|
||||||
|
}
|
||||||
|
mfm.IsForum = true
|
||||||
|
} else if topicID != 0 {
|
||||||
|
return nil, nil, fmt.Errorf("channel %d is not a forum, cannot have topics", chat.GetID())
|
||||||
|
}
|
||||||
|
left = chat.Left
|
||||||
|
if chat.Broadcast {
|
||||||
|
info.Members.MemberMap.Set(bridgev2.ChatMember{
|
||||||
|
EventSender: bridgev2.EventSender{Sender: ids.MakeChannelUserID(chat.GetID())},
|
||||||
|
PowerLevel: superadminPowerLevel,
|
||||||
|
})
|
||||||
|
} else if chat.Megagroup && !tc.main.Config.ShouldBridge(chat.ParticipantsCount) {
|
||||||
|
// TODO change this to a better error whenever that is implemented in mautrix-go
|
||||||
|
return nil, nil, fmt.Errorf("too many participants (%d) in chat %d", chat.ParticipantsCount, chat.GetID())
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("unsupported chat type %T", rawChat)
|
||||||
|
}
|
||||||
|
if avatarErr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to wrap chat avatar: %w", avatarErr)
|
||||||
|
}
|
||||||
|
if !left {
|
||||||
|
info.Members.MemberMap.Add(bridgev2.ChatMember{EventSender: tc.mySender(), PowerLevel: ownPL})
|
||||||
|
}
|
||||||
|
info.ExtraUpdates = func(ctx context.Context, portal *bridgev2.Portal) bool {
|
||||||
|
meta := portal.Metadata.(*PortalMetadata)
|
||||||
|
_ = updatePortalLastSyncAt(ctx, portal)
|
||||||
|
changed := meta.SetIsSuperGroup(isMegagroup)
|
||||||
|
changed = meta.SetIsForumGeneral(isForumGeneral) || changed
|
||||||
|
if info.Members.TotalMemberCount != 0 && meta.ParticipantsCount != info.Members.TotalMemberCount {
|
||||||
|
meta.ParticipantsCount = info.Members.TotalMemberCount
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
return &info, &mfm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) overrideChatInfoWithTopic(info *bridgev2.ChatInfo, topic *tg.ForumTopic) {
|
||||||
|
info.Name = ptr.Ptr(topic.Title + " - " + *info.Name)
|
||||||
|
if topic.Closed {
|
||||||
|
info.Members.PowerLevels.EventsDefault = nobodyPowerLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) getChannelParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (*tg.ChannelsChannelParticipants, error) {
|
||||||
|
return APICallWithUpdates(ctx, tc, func() (*tg.ChannelsChannelParticipants, error) {
|
||||||
|
p, err := tc.client.API().ChannelsGetParticipants(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
participants, _ := p.(*tg.ChannelsChannelParticipants)
|
||||||
|
return participants, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) fillChannelMembers(ctx context.Context, mfm *memberFetchMeta, info *bridgev2.ChatMemberList) error {
|
||||||
|
if mfm.Input == nil || mfm.ParticipantsHidden || (mfm.IsBroadcast && !tc.main.Config.MemberList.SyncBroadcastChannels) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
memberSyncLimit := tc.main.Config.MemberList.NormalizedMaxInitialSync()
|
||||||
|
|
||||||
|
if memberSyncLimit <= 200 {
|
||||||
|
participants, err := tc.getChannelParticipants(ctx, &tg.ChannelsGetParticipantsRequest{
|
||||||
|
Channel: mfm.Input,
|
||||||
|
Filter: &tg.ChannelParticipantsRecent{},
|
||||||
|
Limit: memberSyncLimit,
|
||||||
|
})
|
||||||
|
if err != nil || participants == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info.IsFull = len(participants.Participants) < memberSyncLimit &&
|
||||||
|
len(participants.Participants) >= info.TotalMemberCount &&
|
||||||
|
info.TotalMemberCount > 0
|
||||||
|
for participant := range tc.filterChannelParticipants(participants.Participants, memberSyncLimit) {
|
||||||
|
info.MemberMap.Set(participant)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remaining := memberSyncLimit
|
||||||
|
var offset int
|
||||||
|
for remaining > 0 {
|
||||||
|
participants, err := tc.getChannelParticipants(ctx, &tg.ChannelsGetParticipantsRequest{
|
||||||
|
Channel: mfm.Input,
|
||||||
|
Filter: &tg.ChannelParticipantsSearch{},
|
||||||
|
Limit: min(remaining, 200),
|
||||||
|
Offset: offset,
|
||||||
|
})
|
||||||
|
if err != nil || participants == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(participants.Participants) == 0 {
|
||||||
|
info.IsFull = len(info.MemberMap) >= info.TotalMemberCount &&
|
||||||
|
info.TotalMemberCount > 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for participant := range tc.filterChannelParticipants(participants.Participants, remaining) {
|
||||||
|
info.MemberMap.Set(participant)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += len(participants.Participants)
|
||||||
|
remaining -= len(participants.Participants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) fillUserLocalMeta(info *bridgev2.ChatInfo, dialog *tg.Dialog) {
|
||||||
|
info.UserLocal = &bridgev2.UserLocalPortalInfo{}
|
||||||
|
if mu, ok := dialog.NotifySettings.GetMuteUntil(); ok {
|
||||||
|
info.UserLocal.MutedUntil = ptr.Ptr(time.Unix(int64(mu), 0))
|
||||||
|
} else {
|
||||||
|
info.UserLocal.MutedUntil = &bridgev2.Unmuted
|
||||||
|
}
|
||||||
|
if dialog.Pinned {
|
||||||
|
info.UserLocal.Tag = ptr.Ptr(event.RoomTagFavourite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) wrapFullChatInfo(portalID networkid.PortalID, fullChat *tg.MessagesChatFull) (*bridgev2.ChatInfo, *memberFetchMeta, error) {
|
||||||
|
var chat tg.ChatClass
|
||||||
|
for _, c := range fullChat.GetChats() {
|
||||||
|
if c.GetID() == fullChat.FullChat.GetID() {
|
||||||
|
chat = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chat == nil {
|
||||||
|
return nil, nil, fmt.Errorf("chat ID %d not found in full chat", fullChat.FullChat.GetID())
|
||||||
|
}
|
||||||
|
|
||||||
|
info, mfm, err := tc.wrapChatInfo(portalID, chat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAllowedReactions []string
|
||||||
|
if reactions, ok := fullChat.FullChat.GetAvailableReactions(); ok {
|
||||||
|
switch typedReactions := reactions.(type) {
|
||||||
|
case *tg.ChatReactionsAll:
|
||||||
|
newAllowedReactions = nil
|
||||||
|
case *tg.ChatReactionsNone:
|
||||||
|
newAllowedReactions = []string{}
|
||||||
|
case *tg.ChatReactionsSome:
|
||||||
|
newAllowedReactions = make([]string, 0, len(typedReactions.Reactions))
|
||||||
|
for _, react := range typedReactions.Reactions {
|
||||||
|
emoji, ok := react.(*tg.ReactionEmoji)
|
||||||
|
if ok {
|
||||||
|
newAllowedReactions = append(newAllowedReactions, emoji.Emoticon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(newAllowedReactions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ttl, ok := fullChat.FullChat.GetTTLPeriod(); ok {
|
||||||
|
info.Disappear = &database.DisappearingSetting{
|
||||||
|
Type: event.DisappearingTypeAfterSend,
|
||||||
|
Timer: time.Duration(ttl) * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if about := fullChat.FullChat.GetAbout(); about != "" {
|
||||||
|
info.Topic = &about
|
||||||
|
}
|
||||||
|
info.ExtraUpdates = bridgev2.MergeExtraUpdaters(
|
||||||
|
info.ExtraUpdates,
|
||||||
|
reactionUpdater(newAllowedReactions),
|
||||||
|
markFullSynced,
|
||||||
|
)
|
||||||
|
|
||||||
|
switch typedFullChat := fullChat.FullChat.(type) {
|
||||||
|
case *tg.ChatFull:
|
||||||
|
participants, _ := typedFullChat.GetParticipants().(*tg.ChatParticipants)
|
||||||
|
memberSyncLimit := tc.main.Config.MemberList.NormalizedMaxInitialSync()
|
||||||
|
info.Members.IsFull = true
|
||||||
|
for i, user := range participants.GetParticipants() {
|
||||||
|
var powerLevel *int
|
||||||
|
switch user.(type) {
|
||||||
|
case *tg.ChatParticipantCreator:
|
||||||
|
powerLevel = creatorPowerLevel
|
||||||
|
case *tg.ChatParticipantAdmin:
|
||||||
|
powerLevel = modPowerLevel
|
||||||
|
default:
|
||||||
|
powerLevel = ptr.Ptr(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Members.MemberMap.Set(bridgev2.ChatMember{
|
||||||
|
EventSender: tc.senderForUserID(user.GetUserID()),
|
||||||
|
PowerLevel: powerLevel,
|
||||||
|
})
|
||||||
|
|
||||||
|
if i >= memberSyncLimit {
|
||||||
|
info.Members.IsFull = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *tg.ChannelFull:
|
||||||
|
mfm.ParticipantsHidden = !typedFullChat.CanViewParticipants || typedFullChat.ParticipantsHidden
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, mfm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactionUpdater(newAllowedReactions []string) bridgev2.ExtraUpdater[*bridgev2.Portal] {
|
||||||
|
return func(ctx context.Context, portal *bridgev2.Portal) bool {
|
||||||
|
meta := portal.Metadata.(*PortalMetadata)
|
||||||
|
if newAllowedReactions == nil {
|
||||||
|
if meta.AllowedReactions == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
meta.AllowedReactions = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if meta.AllowedReactions == nil || !slices.Equal(newAllowedReactions, meta.AllowedReactions) {
|
||||||
|
meta.AllowedReactions = newAllowedReactions
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markFullSynced(ctx context.Context, portal *bridgev2.Portal) bool {
|
||||||
|
meta := portal.Metadata.(*PortalMetadata)
|
||||||
|
if !meta.FullSynced {
|
||||||
|
meta.FullSynced = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) avatarFromPhoto(ctx context.Context, peerType ids.PeerType, peerID int64, photo tg.PhotoClass) *bridgev2.Avatar {
|
||||||
|
if photo == nil {
|
||||||
|
zerolog.Ctx(ctx).Trace().Msg("Chat photo is nil, returning no avatar")
|
||||||
|
return nil
|
||||||
|
} else if photo.TypeID() != tg.PhotoTypeID {
|
||||||
|
zerolog.Ctx(ctx).Debug().Str("type_name", photo.TypeName()).Msg("Chat photo type unknown, returning no avatar")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
avatar, err := tc.convertPhoto(ctx, peerType, peerID, photo)
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Err(err).Int64("id", photo.GetID()).Msg("Failed to convert avatar")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) filterChannelParticipants(participants []tg.ChannelParticipantClass, limit int) iter.Seq[bridgev2.ChatMember] {
|
||||||
|
return func(yield func(bridgev2.ChatMember) bool) {
|
||||||
|
for i, u := range participants {
|
||||||
|
var member bridgev2.ChatMember
|
||||||
|
switch participant := u.(type) {
|
||||||
|
case *tg.ChannelParticipant:
|
||||||
|
member.EventSender = tc.senderForUserID(participant.GetUserID())
|
||||||
|
member.PowerLevel = anyonePowerLevel
|
||||||
|
case *tg.ChannelParticipantSelf:
|
||||||
|
member.EventSender = tc.senderForUserID(participant.GetUserID())
|
||||||
|
member.PowerLevel = anyonePowerLevel
|
||||||
|
case *tg.ChannelParticipantCreator:
|
||||||
|
member.EventSender = tc.senderForUserID(participant.GetUserID())
|
||||||
|
member.PowerLevel = creatorPowerLevel
|
||||||
|
case *tg.ChannelParticipantAdmin:
|
||||||
|
member.EventSender = tc.senderForUserID(participant.GetUserID())
|
||||||
|
member.PowerLevel = adminRightsToPowerLevel(participant.AdminRights)
|
||||||
|
case *tg.ChannelParticipantBanned:
|
||||||
|
if participant.BannedRights.ViewMessages {
|
||||||
|
member.Membership = event.MembershipBan
|
||||||
|
} else if participant.Left {
|
||||||
|
member.Membership = event.MembershipLeave
|
||||||
|
}
|
||||||
|
if participant.BannedRights.SendMessages {
|
||||||
|
member.PowerLevel = mutedPowerLevel
|
||||||
|
} else {
|
||||||
|
member.PowerLevel = anyonePowerLevel
|
||||||
|
}
|
||||||
|
member.EventSender = tc.getPeerSender(participant.GetPeer())
|
||||||
|
member.MemberSender = tc.senderForUserID(participant.GetKickedBy())
|
||||||
|
case *tg.ChannelParticipantLeft:
|
||||||
|
member.Membership = event.MembershipLeave
|
||||||
|
member.PrevMembership = event.MembershipJoin
|
||||||
|
member.EventSender = tc.getPeerSender(participant.GetPeer())
|
||||||
|
default:
|
||||||
|
// TODO warning log?
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i >= limit && member.Membership == "" && !member.EventSender.IsFromMe {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !yield(member) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
|
||||||
|
peerType, id, topicID, err := ids.ParsePortalID(portal.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch peerType {
|
||||||
|
case ids.PeerTypeUser:
|
||||||
|
return tc.getDMChatInfo(ctx, id)
|
||||||
|
case ids.PeerTypeChat:
|
||||||
|
fullChat, err := APICallWithUpdates(ctx, tc, func() (*tg.MessagesChatFull, error) {
|
||||||
|
return tc.client.API().MessagesGetFullChat(ctx, id)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, _, err := tc.wrapFullChatInfo(portal.ID, fullChat)
|
||||||
|
return info, err
|
||||||
|
case ids.PeerTypeChannel:
|
||||||
|
accessHash, err := tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get channel access hash: %w", err)
|
||||||
|
}
|
||||||
|
if topicID > 0 {
|
||||||
|
resp, err := APICallWithUpdates(ctx, tc, func() (*tg.MessagesForumTopics, error) {
|
||||||
|
return tc.client.API().MessagesGetForumTopicsByID(ctx, &tg.MessagesGetForumTopicsByIDRequest{
|
||||||
|
Peer: &tg.InputPeerChannel{ChannelID: id, AccessHash: accessHash},
|
||||||
|
Topics: []int{topicID},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
channel, topic, err := getTopicInfoFromResponse(resp, id, topicID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, _, err := tc.wrapChatInfo(portal.ID, channel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tc.overrideChatInfoWithTopic(info, topic)
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
fullChat, err := APICallWithUpdates(ctx, tc, func() (*tg.MessagesChatFull, error) {
|
||||||
|
return tc.client.API().ChannelsGetFullChannel(ctx, &tg.InputChannel{ChannelID: id, AccessHash: accessHash})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, mfm, err := tc.wrapFullChatInfo(portal.ID, fullChat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = tc.fillChannelMembers(ctx, mfm, info.Members)
|
||||||
|
if err != nil {
|
||||||
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to get channel members")
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported peer type %s", peerType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTopicInfoFromResponse(resp *tg.MessagesForumTopics, channelID int64, topicID int) (channel *tg.Channel, topic *tg.ForumTopic, err error) {
|
||||||
|
var ok bool
|
||||||
|
for _, ch := range resp.GetChats() {
|
||||||
|
if ch.GetID() == channelID {
|
||||||
|
channel, ok = ch.(*tg.Channel)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("chat ID %d is %T not *tg.Channel", channelID, ch)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channel == nil {
|
||||||
|
return nil, nil, fmt.Errorf("channel ID %d not found in chats", channelID)
|
||||||
|
}
|
||||||
|
for _, tp := range resp.GetTopics() {
|
||||||
|
if tp.GetID() == topicID {
|
||||||
|
topic, ok = tp.(*tg.ForumTopic)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("topic ID %d is %T not *tg.ForumTopic", topicID, tp)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if topic == nil {
|
||||||
|
return nil, nil, fmt.Errorf("topic ID %d not found in topics", topicID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) getDMPowerLevels(ghost *bridgev2.Ghost) *bridgev2.PowerLevelOverrides {
|
||||||
|
var plo bridgev2.PowerLevelOverrides
|
||||||
|
// TODO use per-login metadata for blocked status
|
||||||
|
if /*ghost.Metadata.(*GhostMetadata).Blocked*/ false {
|
||||||
|
// Don't allow sending messages to blocked users
|
||||||
|
plo.EventsDefault = superadminPowerLevel
|
||||||
|
} else {
|
||||||
|
plo.EventsDefault = anyonePowerLevel
|
||||||
|
}
|
||||||
|
plo.Events = map[event.Type]int{
|
||||||
|
event.StateRoomName: 0,
|
||||||
|
event.StateRoomAvatar: 0,
|
||||||
|
event.StateTopic: 0,
|
||||||
|
event.StateBeeperDisappearingTimer: 0,
|
||||||
|
event.BeeperDeleteChat: 0,
|
||||||
|
}
|
||||||
|
return &plo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) getPowerLevelOverridesFromBannedRights(entity tg.ChatClass, dbr tg.ChatBannedRights) *bridgev2.PowerLevelOverrides {
|
||||||
|
var plo bridgev2.PowerLevelOverrides
|
||||||
|
plo.Ban = banUsersPowerLevel
|
||||||
|
plo.Kick = banUsersPowerLevel
|
||||||
|
plo.Redact = deleteMessagesPowerLevel
|
||||||
|
if dbr.InviteUsers {
|
||||||
|
plo.Invite = inviteUsersPowerLevel
|
||||||
|
} else {
|
||||||
|
plo.Invite = anyonePowerLevel
|
||||||
|
}
|
||||||
|
plo.StateDefault = superadminPowerLevel
|
||||||
|
plo.UsersDefault = anyonePowerLevel
|
||||||
|
if c, ok := entity.(*tg.Channel); (ok && !c.Megagroup) || dbr.SendMessages {
|
||||||
|
plo.EventsDefault = postMessagesPowerLevel
|
||||||
|
} else {
|
||||||
|
plo.EventsDefault = anyonePowerLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
plo.Events = map[event.Type]int{
|
||||||
|
event.StateEncryption: 99,
|
||||||
|
event.StateTombstone: 99,
|
||||||
|
event.StatePowerLevels: 85,
|
||||||
|
event.StateHistoryVisibility: 85,
|
||||||
|
event.StateBeeperDisappearingTimer: 85,
|
||||||
|
event.BeeperDeleteChat: *creatorPowerLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbr.ChangeInfo {
|
||||||
|
plo.Events[event.StateRoomName] = *changeInfoPowerLevel
|
||||||
|
plo.Events[event.StateRoomAvatar] = *changeInfoPowerLevel
|
||||||
|
plo.Events[event.StateTopic] = *changeInfoPowerLevel
|
||||||
|
plo.Events[event.StateBeeperDisappearingTimer] = *changeInfoPowerLevel
|
||||||
|
} else {
|
||||||
|
plo.Events[event.StateRoomName] = 0
|
||||||
|
plo.Events[event.StateRoomAvatar] = 0
|
||||||
|
plo.Events[event.StateTopic] = 0
|
||||||
|
plo.Events[event.StateBeeperDisappearingTimer] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbr.PinMessages {
|
||||||
|
plo.Events[event.StatePinnedEvents] = *pinMessagesPowerLevel
|
||||||
|
} else {
|
||||||
|
plo.Events[event.StatePinnedEvents] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbr.SendStickers {
|
||||||
|
plo.Events[event.EventSticker] = *postMessagesPowerLevel
|
||||||
|
} else {
|
||||||
|
plo.Events[event.EventSticker] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return &plo
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Sumner Evans
|
||||||
|
// Copyright (C) 2026 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/simplevent"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (tc *TelegramClient) syncChats(ctx context.Context, takeoutID int64, onLogin, restart bool) error {
|
||||||
|
if takeoutID != 0 && !tc.main.Config.Takeout.DialogSync {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logWith := zerolog.Ctx(ctx).With().Str("loop", "chat sync")
|
||||||
|
if onLogin {
|
||||||
|
logWith = logWith.Bool("on_login", true)
|
||||||
|
}
|
||||||
|
if takeoutID != 0 {
|
||||||
|
logWith = logWith.Int64("takeout_id", takeoutID)
|
||||||
|
}
|
||||||
|
log := logWith.Logger()
|
||||||
|
|
||||||
|
if !tc.syncChatsLock.TryLock() {
|
||||||
|
log.Warn().Msg("Waiting for chat sync lock")
|
||||||
|
tc.syncChatsLock.Lock()
|
||||||
|
log.Debug().Msg("Acquired chat sync lock after waiting")
|
||||||
|
}
|
||||||
|
defer tc.syncChatsLock.Unlock()
|
||||||
|
|
||||||
|
if restart {
|
||||||
|
tc.metadata.DialogSyncCount = 0
|
||||||
|
tc.metadata.DialogSyncComplete = false
|
||||||
|
tc.metadata.DialogSyncCursor = ""
|
||||||
|
} else if tc.metadata.DialogSyncComplete {
|
||||||
|
log.Debug().Msg("Dialogs already synced")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isFullSync := true
|
||||||
|
updateLimit := subtractLimit(tc.main.Config.Sync.UpdateLimit, tc.metadata.DialogSyncCount)
|
||||||
|
if onLogin && tc.main.Config.Takeout.DialogSync {
|
||||||
|
updateLimit = tc.main.Config.Sync.LoginLimit
|
||||||
|
isFullSync = false
|
||||||
|
}
|
||||||
|
createLimit := subtractLimit(tc.main.Config.Sync.CreateLimit, tc.metadata.DialogSyncCount)
|
||||||
|
|
||||||
|
var req tg.MessagesGetDialogsRequest
|
||||||
|
isFirst := true
|
||||||
|
if tc.metadata.DialogSyncCursor != "" {
|
||||||
|
isFirst = false
|
||||||
|
var err error
|
||||||
|
req.OffsetPeer, _, err = tc.inputPeerForPortalID(ctx, tc.metadata.DialogSyncCursor)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get input peer for pagination: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
req.OffsetPeer = &tg.InputPeerEmpty{}
|
||||||
|
}
|
||||||
|
var wrappedReq bin.Object
|
||||||
|
if takeoutID != 0 {
|
||||||
|
wrappedReq = &tg.InvokeWithTakeoutRequest{TakeoutID: takeoutID, Query: &req}
|
||||||
|
} else {
|
||||||
|
wrappedReq = &req
|
||||||
|
}
|
||||||
|
for updateLimit < 0 || updateLimit > 0 {
|
||||||
|
if updateLimit < 0 {
|
||||||
|
req.Limit = 100
|
||||||
|
} else {
|
||||||
|
req.Limit = min(100, updateLimit)
|
||||||
|
}
|
||||||
|
log.Info().
|
||||||
|
Stringer("request", &req).
|
||||||
|
Int("update_limit", updateLimit).
|
||||||
|
Int("create_limit", createLimit).
|
||||||
|
Msg("Fetching dialogs")
|
||||||
|
dialogs, err := APICallWithUpdates(ctx, tc, func() (tg.ModifiedMessagesDialogs, error) {
|
||||||
|
var dialogs tg.MessagesDialogsBox
|
||||||
|
retry := true
|
||||||
|
attempts := 0
|
||||||
|
var err error
|
||||||
|
for retry && attempts < 5 {
|
||||||
|
retry, err = tgerr.FloodWait(ctx, tc.client.Invoke(ctx, wrappedReq, &dialogs))
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if modified, ok := dialogs.Dialogs.AsModified(); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected response type: %T", dialogs.Dialogs)
|
||||||
|
} else {
|
||||||
|
return modified, nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get dialogs: %w", err)
|
||||||
|
} else if len(dialogs.GetDialogs()) == 0 {
|
||||||
|
log.Debug().Msg("No more dialogs found (empty response)")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if isFirst {
|
||||||
|
// This is the first fetch of dialogs, reset the pinned dialogs based on the list.
|
||||||
|
if err = tc.resetPinnedDialogs(ctx, dialogs.GetDialogs()); err != nil {
|
||||||
|
return fmt.Errorf("failed to save pinned dialogs: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isFirst = false
|
||||||
|
|
||||||
|
dialogList := dialogs.GetDialogs()
|
||||||
|
if updateLimit > 0 && len(dialogList) > updateLimit {
|
||||||
|
dialogList = dialogList[:updateLimit]
|
||||||
|
}
|
||||||
|
err = tc.handleDialogs(ctx, dialogList, dialogs, createLimit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to handle dialogs: %w", err)
|
||||||
|
}
|
||||||
|
updateLimit = subtractLimit(updateLimit, len(dialogList))
|
||||||
|
createLimit = subtractLimit(createLimit, len(dialogList))
|
||||||
|
|
||||||
|
cursorPortalKey := tc.makePortalKeyFromPeer(dialogList[len(dialogList)-1].GetPeer(), 0)
|
||||||
|
if tc.metadata.DialogSyncCursor == cursorPortalKey.ID {
|
||||||
|
log.Debug().Msg("No more dialogs found (last dialog is same as old cursor)")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tc.metadata.DialogSyncCursor = cursorPortalKey.ID
|
||||||
|
tc.metadata.DialogSyncCount += len(dialogList)
|
||||||
|
if err = tc.userLogin.Save(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to save user login to update cursor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OffsetPeer, _, err = tc.inputPeerForPortalID(ctx, cursorPortalKey.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get input peer for pagination: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isFullSync {
|
||||||
|
tc.metadata.DialogSyncComplete = true
|
||||||
|
tc.metadata.DialogSyncCursor = ""
|
||||||
|
tc.metadata.DialogSyncCount = 0
|
||||||
|
if err := tc.userLogin.Save(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to save user login after successful sync: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Info().Msg("Finished dialog sync")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func subtractLimit(limit, count int) int {
|
||||||
|
if limit < 0 {
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
limit -= count
|
||||||
|
if limit < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) resetPinnedDialogs(ctx context.Context, dialogs []tg.DialogClass) error {
|
||||||
|
tc.metadata.PinnedDialogs = nil
|
||||||
|
for _, dialog := range dialogs {
|
||||||
|
if dialog.GetPinned() {
|
||||||
|
portalKey := tc.makePortalKeyFromPeer(dialog.GetPeer(), 0)
|
||||||
|
tc.metadata.PinnedDialogs = append(tc.metadata.PinnedDialogs, portalKey.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tc.userLogin.Save(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) handleDialogs(ctx context.Context, dialogList []tg.DialogClass, meta tg.ModifiedMessagesDialogs, createLimit int) error {
|
||||||
|
log := zerolog.Ctx(ctx)
|
||||||
|
|
||||||
|
users := map[int64]tg.UserClass{}
|
||||||
|
for _, user := range meta.GetUsers() {
|
||||||
|
users[user.GetID()] = user
|
||||||
|
}
|
||||||
|
chats := map[int64]tg.ChatClass{}
|
||||||
|
for _, chat := range meta.GetChats() {
|
||||||
|
chats[chat.GetID()] = chat
|
||||||
|
}
|
||||||
|
messages := map[networkid.MessageID]tg.MessageClass{}
|
||||||
|
for _, message := range meta.GetMessages() {
|
||||||
|
messages[ids.GetMessageIDFromMessage(message)] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, d := range dialogList {
|
||||||
|
dialog, ok := d.(*tg.Dialog)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log := log.With().
|
||||||
|
Stringer("peer", dialog.Peer).
|
||||||
|
Int("top_message", dialog.TopMessage).
|
||||||
|
Logger()
|
||||||
|
log.Debug().Msg("Syncing dialog")
|
||||||
|
|
||||||
|
portalKey := tc.makePortalKeyFromPeer(dialog.GetPeer(), 0)
|
||||||
|
portal, err := tc.main.Bridge.GetPortalByKey(ctx, portalKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dialog.UnreadCount == 0 && !dialog.UnreadMark {
|
||||||
|
portal.Metadata.(*PortalMetadata).ReadUpTo = dialog.TopMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatInfo *bridgev2.ChatInfo
|
||||||
|
switch peer := dialog.Peer.(type) {
|
||||||
|
case *tg.PeerUser:
|
||||||
|
switch user := users[peer.UserID].(type) {
|
||||||
|
case *tg.User:
|
||||||
|
if user.GetDeleted() {
|
||||||
|
log.Debug().Int64("user_id", peer.UserID).Msg("Not syncing portal because user is deleted")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chatInfo, err = tc.getDMChatInfo(ctx, peer.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get dm info for %d: %w", peer.UserID, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Debug().
|
||||||
|
Int64("user_id", peer.UserID).
|
||||||
|
Type("user_type", user).
|
||||||
|
Msg("Not syncing portal because user type is unsupported")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case *tg.PeerChat:
|
||||||
|
switch chat := chats[peer.ChatID].(type) {
|
||||||
|
case *tg.Chat:
|
||||||
|
// Need to get full chat info to get the member list
|
||||||
|
chatInfo, err = tc.GetChatInfo(ctx, portal)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get chat info for %s: %w", portalKey, err)
|
||||||
|
}
|
||||||
|
case *tg.ChatForbidden:
|
||||||
|
log.Debug().
|
||||||
|
Int64("chat_id", peer.ChatID).
|
||||||
|
Msg("Not syncing portal because chat is forbidden")
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
log.Debug().
|
||||||
|
Int64("chat_id", peer.ChatID).
|
||||||
|
Type("chat_type", chat).
|
||||||
|
Msg("Not syncing portal because chat type is unsupported")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case *tg.PeerChannel:
|
||||||
|
switch channel := chats[peer.ChannelID].(type) {
|
||||||
|
case *tg.Channel:
|
||||||
|
var mfm *memberFetchMeta
|
||||||
|
chatInfo, mfm, err = tc.wrapChatInfo(portal.ID, channel)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get chat info for %s: %w", portalKey, err)
|
||||||
|
}
|
||||||
|
err = tc.fillChannelMembers(ctx, mfm, chatInfo.Members)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to get channel members")
|
||||||
|
}
|
||||||
|
case *tg.ChannelForbidden:
|
||||||
|
log.Debug().
|
||||||
|
Int64("channel_id", peer.ChannelID).
|
||||||
|
Msg("Not syncing portal because channel is forbidden")
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
log.Debug().
|
||||||
|
Int64("channel_id", peer.ChannelID).
|
||||||
|
Type("channel_type", channel).
|
||||||
|
Msg("Not syncing portal because channel type is unsupported")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if portal.MXID == "" {
|
||||||
|
// Check what the latest message is
|
||||||
|
topMessage := messages[ids.MakeMessageID(dialog.Peer, dialog.TopMessage)]
|
||||||
|
if topMessage == nil {
|
||||||
|
if dialog.TopMessage == 0 {
|
||||||
|
log.Debug().Msg("Not syncing portal because there are no messages")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Warn().Msg("TopMessage of dialog not in messages map")
|
||||||
|
} else if topMessage.TypeID() == tg.MessageServiceTypeID {
|
||||||
|
action := topMessage.(*tg.MessageService).Action
|
||||||
|
if action.TypeID() == tg.MessageActionContactSignUpTypeID || action.TypeID() == tg.MessageActionHistoryClearTypeID {
|
||||||
|
log.Debug().Str("action_type", action.TypeName()).Msg("Not syncing portal because it's a contact sign up or history clear")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if createLimit >= 0 && i >= createLimit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.fillUserLocalMeta(chatInfo, dialog)
|
||||||
|
|
||||||
|
res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ChatResync{
|
||||||
|
ChatInfo: chatInfo,
|
||||||
|
EventMeta: simplevent.EventMeta{
|
||||||
|
Type: bridgev2.RemoteEventChatResync,
|
||||||
|
LogContext: func(c zerolog.Context) zerolog.Context {
|
||||||
|
return c.Str("update", "sync")
|
||||||
|
},
|
||||||
|
PortalKey: portalKey,
|
||||||
|
CreatePortal: true,
|
||||||
|
},
|
||||||
|
CheckNeedsBackfillFunc: func(ctx context.Context, latestMessage *database.Message) (bool, error) {
|
||||||
|
if latestMessage == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
_, latestMessageID, err := ids.ParseMessageID(latestMessage.ID)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return dialog.TopMessage > latestMessageID, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err = resultToError(res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a read receipt from the last known read message id
|
||||||
|
res = tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.Receipt{
|
||||||
|
EventMeta: simplevent.EventMeta{
|
||||||
|
Type: bridgev2.RemoteEventReadReceipt,
|
||||||
|
PortalKey: portalKey,
|
||||||
|
Sender: tc.mySender(),
|
||||||
|
},
|
||||||
|
LastTarget: ids.MakeMessageID(portalKey, dialog.ReadInboxMaxID),
|
||||||
|
ReadUpToStreamOrder: int64(dialog.ReadInboxMaxID),
|
||||||
|
})
|
||||||
|
if err = resultToError(res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"go.mau.fi/util/exsync"
|
||||||
|
"go.mau.fi/zerozap"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/database"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/simplevent"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/status"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/humanise"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/matrixfmt"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/store"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/pool"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/auth"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/updates"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoAuthKey = errors.New("user does not have auth key")
|
||||||
|
ErrFailToQueueEvent = errors.New("failed to queue event")
|
||||||
|
)
|
||||||
|
|
||||||
|
func resultToError(res bridgev2.EventHandlingResult) error {
|
||||||
|
if !res.Success {
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf("%w: %w", ErrFailToQueueEvent, res.Error)
|
||||||
|
}
|
||||||
|
return ErrFailToQueueEvent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramClient struct {
|
||||||
|
main *TelegramConnector
|
||||||
|
ScopedStore *store.ScopedStore
|
||||||
|
telegramUserID int64
|
||||||
|
loginID networkid.UserLoginID
|
||||||
|
userID networkid.UserID
|
||||||
|
userLogin *bridgev2.UserLogin
|
||||||
|
metadata *UserLoginMetadata
|
||||||
|
client *telegram.Client
|
||||||
|
updatesManager *updates.Manager
|
||||||
|
dispatcher tg.UpdateDispatcher
|
||||||
|
clientCtx context.Context
|
||||||
|
clientCancel context.CancelFunc
|
||||||
|
clientDone *exsync.Event
|
||||||
|
clientInitialized *exsync.Event
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
appConfigLock sync.Mutex
|
||||||
|
appConfig map[string]any
|
||||||
|
appConfigHash int
|
||||||
|
|
||||||
|
availableReactionsLock sync.Mutex
|
||||||
|
availableReactions map[string]struct{}
|
||||||
|
availableReactionsHash int
|
||||||
|
availableReactionsFetched time.Time
|
||||||
|
availableReactionsList []string
|
||||||
|
isPremiumCache atomic.Bool
|
||||||
|
|
||||||
|
recentMessageRooms *exsync.RingBuffer[networkid.MessageID, networkid.PortalKey]
|
||||||
|
|
||||||
|
telegramFmtParams *telegramfmt.FormatParams
|
||||||
|
matrixParser *matrixfmt.HTMLParser
|
||||||
|
|
||||||
|
cachedContacts *tg.ContactsContacts
|
||||||
|
cachedContactsHash int64
|
||||||
|
contactsLock sync.Mutex
|
||||||
|
lastContactReq time.Time
|
||||||
|
|
||||||
|
dcTransferLock sync.Mutex
|
||||||
|
|
||||||
|
takeoutLock sync.Mutex
|
||||||
|
takeoutAccepted *exsync.Event
|
||||||
|
stopTakeoutTimer *time.Timer
|
||||||
|
takeoutDialogsOnce sync.Once
|
||||||
|
syncChatsLock sync.Mutex
|
||||||
|
isNewLogin bool
|
||||||
|
|
||||||
|
prevReactionPoll map[networkid.PortalKey]time.Time
|
||||||
|
prevReactionPollLock sync.Mutex
|
||||||
|
|
||||||
|
stickerPacksByName map[string]*stickerPackCache
|
||||||
|
stickerPacksByID map[int64]*stickerPackCache
|
||||||
|
stickerPackCacheLock sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ bridgev2.NetworkAPI = (*TelegramClient)(nil)
|
||||||
|
|
||||||
|
var messageLinkRegex = regexp.MustCompile(`^https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,31}[A-Za-z0-9]|[Cc]/[0-9]{1,20})/([0-9]{1,20})(?:/([0-9]{1,20}))?$`)
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) deviceConfig() telegram.DeviceConfig {
|
||||||
|
return telegram.DeviceConfig{
|
||||||
|
DeviceModel: tc.Config.DeviceInfo.DeviceModel,
|
||||||
|
SystemVersion: tc.Config.DeviceInfo.SystemVersion,
|
||||||
|
AppVersion: tc.Config.DeviceInfo.AppVersion,
|
||||||
|
SystemLangCode: tc.Config.DeviceInfo.SystemLangCode,
|
||||||
|
LangCode: tc.Config.DeviceInfo.LangCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var zapLevelMap = map[zapcore.Level]zerolog.Level{
|
||||||
|
// shifted
|
||||||
|
zapcore.DebugLevel: zerolog.TraceLevel,
|
||||||
|
zapcore.InfoLevel: zerolog.DebugLevel,
|
||||||
|
|
||||||
|
// direct mapping
|
||||||
|
zapcore.WarnLevel: zerolog.WarnLevel,
|
||||||
|
zapcore.ErrorLevel: zerolog.ErrorLevel,
|
||||||
|
zapcore.DPanicLevel: zerolog.PanicLevel,
|
||||||
|
zapcore.PanicLevel: zerolog.PanicLevel,
|
||||||
|
zapcore.FatalLevel: zerolog.FatalLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTelegramClient(ctx context.Context, tc *TelegramConnector, login *bridgev2.UserLogin) (*TelegramClient, error) {
|
||||||
|
telegramUserID, err := ids.ParseUserLoginID(login.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log := zerolog.Ctx(ctx).With().
|
||||||
|
Str("component", "telegram_client").
|
||||||
|
Str("user_login_id", string(login.ID)).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
zaplog := zap.New(zerozap.NewWithLevels(log, zapLevelMap))
|
||||||
|
|
||||||
|
client := TelegramClient{
|
||||||
|
ScopedStore: tc.Store.GetScopedStore(telegramUserID),
|
||||||
|
|
||||||
|
main: tc,
|
||||||
|
telegramUserID: telegramUserID,
|
||||||
|
loginID: login.ID,
|
||||||
|
userID: networkid.UserID(login.ID),
|
||||||
|
userLogin: login,
|
||||||
|
metadata: login.Metadata.(*UserLoginMetadata),
|
||||||
|
|
||||||
|
takeoutAccepted: exsync.NewEvent(),
|
||||||
|
|
||||||
|
prevReactionPoll: map[networkid.PortalKey]time.Time{},
|
||||||
|
stickerPacksByName: map[string]*stickerPackCache{},
|
||||||
|
stickerPacksByID: map[int64]*stickerPackCache{},
|
||||||
|
|
||||||
|
recentMessageRooms: exsync.NewRingBuffer[networkid.MessageID, networkid.PortalKey](32),
|
||||||
|
|
||||||
|
clientInitialized: exsync.NewEvent(),
|
||||||
|
clientDone: exsync.NewEvent(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !login.Metadata.(*UserLoginMetadata).Session.HasAuthKey() {
|
||||||
|
return &client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client.dispatcher = tg.NewUpdateDispatcher()
|
||||||
|
client.dispatcher.OnFallback(client.onUpdateWrapper)
|
||||||
|
|
||||||
|
client.updatesManager = updates.New(updates.Config{
|
||||||
|
OnNotChannelMember: client.onNotChannelMember,
|
||||||
|
OnChannelTooLong: func(channelID int64) error {
|
||||||
|
// TODO resync topics?
|
||||||
|
res := tc.Bridge.QueueRemoteEvent(login, &simplevent.ChatResync{
|
||||||
|
EventMeta: simplevent.EventMeta{
|
||||||
|
Type: bridgev2.RemoteEventChatResync,
|
||||||
|
LogContext: func(c zerolog.Context) zerolog.Context {
|
||||||
|
return c.Str("update", "channel_too_long").Int64("channel_id", channelID)
|
||||||
|
},
|
||||||
|
PortalKey: client.makePortalKeyFromID(ids.PeerTypeChannel, channelID, 0),
|
||||||
|
},
|
||||||
|
CheckNeedsBackfillFunc: func(ctx context.Context, latestMessage *database.Message) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return resultToError(res)
|
||||||
|
},
|
||||||
|
Handler: client.dispatcher,
|
||||||
|
Logger: zaplog.Named("gaps"),
|
||||||
|
Storage: client.ScopedStore,
|
||||||
|
AccessHasher: client.ScopedStore,
|
||||||
|
})
|
||||||
|
resolver, err := GetProxyResolver(tc.Config.ProxyConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client.client = telegram.NewClient(tc.Config.APIID, tc.Config.APIHash, telegram.Options{
|
||||||
|
CustomSessionStorage: &login.Metadata.(*UserLoginMetadata).Session,
|
||||||
|
Logger: zaplog,
|
||||||
|
UpdateHandler: client.updatesManager,
|
||||||
|
Resolver: resolver,
|
||||||
|
OnDead: client.onDead,
|
||||||
|
OnSession: client.onSession,
|
||||||
|
OnConnected: client.onConnected,
|
||||||
|
OnTransfer: client.onTransfer,
|
||||||
|
PingCallback: client.onPing,
|
||||||
|
OnAuthError: client.onAuthError,
|
||||||
|
PingTimeout: time.Duration(tc.Config.Ping.TimeoutSeconds) * time.Second,
|
||||||
|
PingInterval: time.Duration(tc.Config.Ping.IntervalSeconds) * time.Second,
|
||||||
|
Device: tc.deviceConfig(),
|
||||||
|
})
|
||||||
|
|
||||||
|
client.telegramFmtParams = &telegramfmt.FormatParams{
|
||||||
|
GetUserInfoByID: func(ctx context.Context, id int64) (telegramfmt.UserInfo, error) {
|
||||||
|
ghost, err := tc.Bridge.GetGhostByID(ctx, ids.MakeUserID(id))
|
||||||
|
if err != nil {
|
||||||
|
return telegramfmt.UserInfo{}, err
|
||||||
|
}
|
||||||
|
userInfo := telegramfmt.UserInfo{MXID: ghost.Intent.GetMXID(), Name: ghost.Name}
|
||||||
|
// FIXME this should look for user logins by ID, not hardcode the current user
|
||||||
|
if id == client.telegramUserID {
|
||||||
|
userInfo.MXID = client.userLogin.UserMXID
|
||||||
|
} else if login := tc.Bridge.GetCachedUserLoginByID(ids.MakeUserLoginID(id)); login != nil {
|
||||||
|
userInfo.MXID = login.UserMXID
|
||||||
|
}
|
||||||
|
return userInfo, nil
|
||||||
|
},
|
||||||
|
GetUserInfoByUsername: func(ctx context.Context, username string) (telegramfmt.UserInfo, error) {
|
||||||
|
if peerType, userID, err := client.main.Store.Username.GetEntityID(ctx, username); err != nil {
|
||||||
|
return telegramfmt.UserInfo{}, err
|
||||||
|
} else if peerType != ids.PeerTypeUser {
|
||||||
|
return telegramfmt.UserInfo{}, fmt.Errorf("unexpected peer type: %s", peerType)
|
||||||
|
} else if ghost, err := tc.Bridge.GetGhostByID(ctx, ids.MakeUserID(userID)); err != nil {
|
||||||
|
return telegramfmt.UserInfo{}, err
|
||||||
|
} else {
|
||||||
|
userInfo := telegramfmt.UserInfo{MXID: ghost.Intent.GetMXID(), Name: ghost.Name}
|
||||||
|
if ghost.ID == client.userID {
|
||||||
|
userInfo.MXID = client.userLogin.UserMXID
|
||||||
|
} else if login := tc.Bridge.GetCachedUserLoginByID(ids.MakeUserLoginID(userID)); login != nil {
|
||||||
|
userInfo.MXID = login.UserMXID
|
||||||
|
}
|
||||||
|
return userInfo, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
NormalizeURL: func(ctx context.Context, url string) string {
|
||||||
|
log := zerolog.Ctx(ctx).With().
|
||||||
|
Str("conversion_direction", "to_matrix").
|
||||||
|
Str("entity_type", "url").
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "ftp://") && !strings.HasPrefix(url, "magnet://") {
|
||||||
|
url = "http://" + url
|
||||||
|
}
|
||||||
|
|
||||||
|
submatches := messageLinkRegex.FindStringSubmatch(url)
|
||||||
|
if len(submatches) == 0 {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
group := submatches[1]
|
||||||
|
msgID, err := strconv.Atoi(submatches[2])
|
||||||
|
if err != nil {
|
||||||
|
log.Trace().Err(err).Str("url", url).Msg("Failed to parse message/topic ID in t.me link")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
var topicID int
|
||||||
|
if len(submatches) == 4 && submatches[3] != "" {
|
||||||
|
lastID, err := strconv.Atoi(submatches[3])
|
||||||
|
if err != nil {
|
||||||
|
log.Trace().Err(err).Str("url", url).Msg("Failed to parse message ID in t.me link")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
topicID = msgID
|
||||||
|
msgID = lastID
|
||||||
|
}
|
||||||
|
log = log.With().Str("group", group).Int("topic_id", topicID).Int("msg_id", msgID).Logger()
|
||||||
|
|
||||||
|
var portalKey networkid.PortalKey
|
||||||
|
if strings.HasPrefix(group, "C/") || strings.HasPrefix(group, "c/") {
|
||||||
|
chatID, err := strconv.ParseInt(submatches[1][2:], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace().Err(err).Str("url", url).Msg("Failed to parse channel ID in t.me link")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
portalKey = client.makePortalKeyFromID(ids.PeerTypeChannel, chatID, topicID)
|
||||||
|
} else if submatches[1] == "premium" {
|
||||||
|
portalKey = client.makePortalKeyFromID(ids.PeerTypeUser, 777000, 0)
|
||||||
|
} else if userID, err := strconv.ParseInt(submatches[1], 10, 64); err == nil && userID > 0 {
|
||||||
|
portalKey = client.makePortalKeyFromID(ids.PeerTypeUser, userID, 0)
|
||||||
|
} else if peerType, peerID, err := client.main.Store.Username.GetEntityID(ctx, submatches[1]); err != nil {
|
||||||
|
log.Err(err).Msg("Failed to get entity ID by username")
|
||||||
|
return url
|
||||||
|
} else if peerType != "" {
|
||||||
|
portalKey = client.makePortalKeyFromID(peerType, peerID, topicID)
|
||||||
|
} else {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
portal, err := tc.Bridge.DB.Portal.GetByKey(ctx, portalKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to get portal referenced by link in text")
|
||||||
|
return url
|
||||||
|
} else if portal == nil {
|
||||||
|
log.Trace().
|
||||||
|
Str("url", url).
|
||||||
|
Msg("Portal referenced by link not found, using t.me link")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := tc.Bridge.DB.Message.GetFirstPartByID(ctx, client.loginID, ids.MakeMessageID(portalKey, msgID))
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to get message referenced by link in text")
|
||||||
|
return url
|
||||||
|
} else if message == nil {
|
||||||
|
log.Trace().
|
||||||
|
Str("url", url).
|
||||||
|
Msg("Message referenced by link not found, using t.me link")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
return portal.MXID.EventURI(message.MXID, tc.Bridge.Matrix.ServerName()).MatrixToURL()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client.matrixParser = &matrixfmt.HTMLParser{
|
||||||
|
Store: tc.Store,
|
||||||
|
Bridge: tc.Bridge,
|
||||||
|
ScopedStore: client.ScopedStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &client, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) onDead() {
|
||||||
|
prevState := tc.userLogin.BridgeState.GetPrev().StateEvent
|
||||||
|
if slices.Contains([]status.BridgeStateEvent{
|
||||||
|
status.StateTransientDisconnect,
|
||||||
|
status.StateBadCredentials,
|
||||||
|
status.StateLoggedOut,
|
||||||
|
status.StateUnknownError,
|
||||||
|
}, prevState) {
|
||||||
|
tc.userLogin.Log.Warn().
|
||||||
|
Str("prev_state", string(prevState)).
|
||||||
|
Msg("client is dead, not sending transient disconnect, because already in an error state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tc.userLogin.BridgeState.Send(status.BridgeState{
|
||||||
|
StateEvent: status.StateTransientDisconnect,
|
||||||
|
Message: "Telegram client disconnected",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) sendBadCredentialsOrUnknownError(err error) {
|
||||||
|
if auth.IsUnauthorized(err) || errors.Is(err, ErrNoAuthKey) {
|
||||||
|
tc.userLogin.BridgeState.Send(status.BridgeState{
|
||||||
|
StateEvent: status.StateBadCredentials,
|
||||||
|
Error: "tg-no-auth",
|
||||||
|
Message: humanise.Error(err),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
tc.userLogin.BridgeState.Send(status.BridgeState{
|
||||||
|
StateEvent: status.StateUnknownError,
|
||||||
|
Error: "tg-unknown-error",
|
||||||
|
Message: humanise.Error(err),
|
||||||
|
Info: map[string]any{
|
||||||
|
"go_error": err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) onPing() {
|
||||||
|
prev := tc.userLogin.BridgeState.GetPrev()
|
||||||
|
if prev.StateEvent == status.StateConnected || prev.Error == updateHandlerStuck {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := tc.userLogin.Log.WithContext(tc.main.Bridge.BackgroundCtx)
|
||||||
|
tc.userLogin.Log.Debug().Msg("Got ping while not connected, checking auth")
|
||||||
|
|
||||||
|
me, err := tc.client.Self(ctx)
|
||||||
|
if auth.IsUnauthorized(err) {
|
||||||
|
tc.onAuthError(err)
|
||||||
|
} else if errors.Is(err, syscall.EPIPE) || errors.Is(err, pool.ErrConnDead) {
|
||||||
|
// Connectivity error — connection died during the Self() call.
|
||||||
|
// Keep as transient; gotd's backoff will reconnect.
|
||||||
|
tc.userLogin.BridgeState.Send(status.BridgeState{
|
||||||
|
StateEvent: status.StateTransientDisconnect,
|
||||||
|
Error: "connectivity-error",
|
||||||
|
Message: humanise.Error(err),
|
||||||
|
})
|
||||||
|
} else if err != nil {
|
||||||
|
tc.sendBadCredentialsOrUnknownError(err)
|
||||||
|
} else {
|
||||||
|
tc.onConnected(me)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) userToRemoteProfile(
|
||||||
|
self *tg.User,
|
||||||
|
ghost *bridgev2.Ghost,
|
||||||
|
prevState *status.RemoteProfile,
|
||||||
|
) (profile status.RemoteProfile, name string) {
|
||||||
|
profile.Name = tc.Config.FormatDisplayname(self.FirstName, self.LastName, self.Username, self.Deleted, self.ID)
|
||||||
|
if self.Phone != "" {
|
||||||
|
profile.Phone = "+" + strings.TrimPrefix(self.Phone, "+")
|
||||||
|
} else if prevState != nil {
|
||||||
|
profile.Phone = prevState.Phone
|
||||||
|
}
|
||||||
|
profile.Username = self.Username
|
||||||
|
if self.Username == "" && len(self.Usernames) > 0 {
|
||||||
|
profile.Username = self.Usernames[0].Username
|
||||||
|
}
|
||||||
|
if ghost != nil {
|
||||||
|
profile.Avatar = ghost.AvatarMXC
|
||||||
|
} else if prevState != nil {
|
||||||
|
profile.Avatar = prevState.Avatar
|
||||||
|
}
|
||||||
|
name = cmp.Or(profile.Username, profile.Phone, profile.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) updateRemoteProfile(ctx context.Context, self *tg.User, ghost *bridgev2.Ghost) bool {
|
||||||
|
newProfile, newName := tc.main.userToRemoteProfile(self, ghost, &tc.userLogin.RemoteProfile)
|
||||||
|
if tc.userLogin.RemoteProfile != newProfile || tc.userLogin.RemoteName != newName {
|
||||||
|
tc.userLogin.RemoteProfile = newProfile
|
||||||
|
tc.userLogin.RemoteName = newName
|
||||||
|
err := tc.userLogin.Save(ctx)
|
||||||
|
if err != nil {
|
||||||
|
tc.userLogin.Log.Err(err).Msg("Failed to save user login after profile update")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) onConnected(self *tg.User) {
|
||||||
|
log := tc.userLogin.Log
|
||||||
|
ctx := log.WithContext(tc.main.Bridge.BackgroundCtx)
|
||||||
|
ghost, err := tc.main.Bridge.GetGhostByID(ctx, tc.userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to get own ghost")
|
||||||
|
} else if wrapped, err := tc.wrapUserInfo(ctx, self, ghost); err != nil {
|
||||||
|
log.Err(err).Msg("Failed to wrap own user info")
|
||||||
|
} else {
|
||||||
|
ghost.UpdateInfo(ctx, wrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.updateRemoteProfile(ctx, self, ghost)
|
||||||
|
tc.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) onTransfer(ctx context.Context, _ *telegram.Client, fn func(context.Context) error) error {
|
||||||
|
tc.userLogin.Log.Trace().Msg("Doing DC auth transfer")
|
||||||
|
tc.dcTransferLock.Lock()
|
||||||
|
defer tc.dcTransferLock.Unlock()
|
||||||
|
return fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) onSession() {
|
||||||
|
tc.userLogin.Log.Debug().Msg("Got session created event")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) onAuthError(err error) {
|
||||||
|
tc.sendBadCredentialsOrUnknownError(err)
|
||||||
|
tc.metadata.ResetOnLogout()
|
||||||
|
go func() {
|
||||||
|
tc.Disconnect()
|
||||||
|
if err := tc.userLogin.Save(context.Background()); err != nil {
|
||||||
|
tc.main.Bridge.Log.Err(err).Msg("failed to save user login")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) Connect(ctx context.Context) {
|
||||||
|
tc.mu.Lock()
|
||||||
|
defer tc.mu.Unlock()
|
||||||
|
|
||||||
|
log := zerolog.Ctx(ctx)
|
||||||
|
if !tc.metadata.Session.HasAuthKey() {
|
||||||
|
log.Warn().Msg("user does not have an auth key, sending bad credentials state")
|
||||||
|
tc.sendBadCredentialsOrUnknownError(ErrNoAuthKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
|
||||||
|
|
||||||
|
log.Info().Msg("Connecting client")
|
||||||
|
|
||||||
|
// Add a cancellation layer we can use for explicit Disconnect
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
tc.clientCtx = ctx
|
||||||
|
tc.clientCancel = cancel
|
||||||
|
tc.clientDone.Clear()
|
||||||
|
tc.clientInitialized.Clear()
|
||||||
|
go tc.runInBackground(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) runInBackground(ctx context.Context) {
|
||||||
|
log := zerolog.Ctx(ctx)
|
||||||
|
err := tc.client.Run(ctx, func(ctx context.Context) error {
|
||||||
|
tc.clientInitialized.Set()
|
||||||
|
// If takeout dialog sync is enabled, we assume it'll resume from a getTakeoutID call.
|
||||||
|
// If not, resume dialog sync manually here.
|
||||||
|
if !tc.isNewLogin && !tc.main.Config.Takeout.DialogSync {
|
||||||
|
go func() {
|
||||||
|
if err := tc.syncChats(log.WithContext(tc.clientCtx), 0, false, false); err != nil {
|
||||||
|
log.Err(err).Msg("Failed to resume chat sync")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
log.Info().Msg("Client running, starting updates")
|
||||||
|
err := tc.updatesManager.Run(ctx, tc.client.API(), tc.telegramUserID, updates.AuthOptions{
|
||||||
|
IsBot: tc.metadata.IsBot,
|
||||||
|
})
|
||||||
|
if err != nil && !errors.Is(err, ctx.Err()) {
|
||||||
|
log.Warn().Err(err).AnErr("ctx_err", ctx.Err()).Msg("Update manager exited with error")
|
||||||
|
} else {
|
||||||
|
log.Info().AnErr("ctx_err", ctx.Err()).Msg("Update manager exited without error")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
tc.clientDone.Set()
|
||||||
|
tc.clientInitialized.Set()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).AnErr("ctx_err", ctx.Err()).Msg("Client exited with error")
|
||||||
|
tc.sendBadCredentialsOrUnknownError(err)
|
||||||
|
} else if ctx.Err() == nil {
|
||||||
|
log.Warn().Msg("Client exited unexpectedly")
|
||||||
|
tc.sendBadCredentialsOrUnknownError(fmt.Errorf("unexpectedly disconnected from Telegram"))
|
||||||
|
} else {
|
||||||
|
log.Debug().AnErr("ctx_err", ctx.Err()).Msg("Client exited without error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) Disconnect() {
|
||||||
|
tc.mu.Lock()
|
||||||
|
defer tc.mu.Unlock()
|
||||||
|
|
||||||
|
tc.userLogin.Log.Debug().Msg("Disconnecting client")
|
||||||
|
|
||||||
|
if tc.clientCancel != nil {
|
||||||
|
tc.clientCancel()
|
||||||
|
tc.userLogin.Log.Debug().Msg("Waiting for client disconnection")
|
||||||
|
<-tc.clientDone.GetChan()
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.userLogin.Log.Info().Msg("Disconnect complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) IsLoggedIn() bool {
|
||||||
|
// TODO use less hacky check than context cancellation
|
||||||
|
return tc != nil && tc.client != nil &&
|
||||||
|
tc.clientInitialized.IsSet() && !tc.clientDone.IsSet() &&
|
||||||
|
tc.metadata.Session.HasAuthKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) LogoutRemote(ctx context.Context) {
|
||||||
|
log := zerolog.Ctx(ctx).With().
|
||||||
|
Str("action", "logout_remote").
|
||||||
|
Int64("user_id", tc.telegramUserID).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
log.Info().Msg("Logging out and disconnecting")
|
||||||
|
|
||||||
|
if tc.metadata.Session.HasAuthKey() {
|
||||||
|
log.Info().Msg("User has an auth key, logging out")
|
||||||
|
|
||||||
|
// logging out is best effort, we want to logout even if we can't call the endpoint
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := tc.client.API().AuthLogOut(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to logout on Telegram")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.Disconnect()
|
||||||
|
|
||||||
|
log.Info().Msg("Deleting user state")
|
||||||
|
|
||||||
|
err := tc.ScopedStore.DeleteUserState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to delete user state")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tc.ScopedStore.DeleteChannelStateForUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to delete channel state for user")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tc.ScopedStore.DeleteAccessHashesForUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to delete access hashes for user")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msg("Logged out and deleted user state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool {
|
||||||
|
return userID == networkid.UserID(tc.userLogin.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) mySender() bridgev2.EventSender {
|
||||||
|
return bridgev2.EventSender{
|
||||||
|
IsFromMe: true,
|
||||||
|
SenderLogin: tc.loginID,
|
||||||
|
Sender: tc.userID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) senderForUserID(userID int64) bridgev2.EventSender {
|
||||||
|
return bridgev2.EventSender{
|
||||||
|
IsFromMe: userID == tc.telegramUserID,
|
||||||
|
SenderLogin: ids.MakeUserLoginID(userID),
|
||||||
|
Sender: ids.MakeUserID(userID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramClient) FillBridgeState(state status.BridgeState) status.BridgeState {
|
||||||
|
if state.Info == nil {
|
||||||
|
state.Info = make(map[string]any)
|
||||||
|
}
|
||||||
|
state.Info["is_bot"] = tc.metadata.IsBot
|
||||||
|
state.Info["login_method"] = tc.metadata.LoginMethod
|
||||||
|
return state
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/commands"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||||
|
"maunium.net/go/mautrix/format"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cmdSyncChats = &commands.FullHandler{
|
||||||
|
Func: fnSyncChats,
|
||||||
|
Name: "sync-chats",
|
||||||
|
Help: commands.HelpMeta{
|
||||||
|
Section: commands.HelpSectionChats,
|
||||||
|
Description: "Synchronize your chats",
|
||||||
|
Args: "[_login ID_]",
|
||||||
|
},
|
||||||
|
RequiresLogin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func fnSyncChats(ce *commands.Event) {
|
||||||
|
logins := ce.User.GetUserLogins()
|
||||||
|
if len(ce.Args) > 0 {
|
||||||
|
logins = slices.DeleteFunc(logins, func(login *bridgev2.UserLogin) bool {
|
||||||
|
return !slices.Contains(ce.Args, string(login.ID))
|
||||||
|
})
|
||||||
|
if len(logins) == 0 {
|
||||||
|
ce.Reply("No matching logins found with provided ID(s)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, login := range logins {
|
||||||
|
client := login.Client.(*TelegramClient)
|
||||||
|
if err := client.syncChats(ce.Ctx, 0, false, true); err != nil {
|
||||||
|
ce.Reply("Failed to synchronize chats for %s: %v", format.SafeMarkdownCode(login.ID), err)
|
||||||
|
} else {
|
||||||
|
ce.Reply("Successfully synchronized chats for %s", format.SafeMarkdownCode(login.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmdUpgrade = &commands.FullHandler{
|
||||||
|
Func: fnUpgrade,
|
||||||
|
Name: "upgrade",
|
||||||
|
Help: commands.HelpMeta{
|
||||||
|
Section: commands.HelpSectionChats,
|
||||||
|
Description: "Upgrade a minigroup to a supergroup on Telegram",
|
||||||
|
},
|
||||||
|
RequiresPortal: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func fnUpgrade(ce *commands.Event) {
|
||||||
|
login, _, err := ce.Portal.FindPreferredLogin(ce.Ctx, ce.User, false)
|
||||||
|
if errors.Is(err, bridgev2.ErrNotLoggedIn) {
|
||||||
|
ce.Reply("No logins found to upgrade the chat")
|
||||||
|
} else if err != nil {
|
||||||
|
ce.Log.Err(err).Msg("Failed to find preferred login for upgrade command")
|
||||||
|
ce.Reply("Failed to find a login to upgrade the chat.")
|
||||||
|
} else if peerType, chatID, _, err := ids.ParsePortalID(ce.Portal.ID); err != nil {
|
||||||
|
ce.Log.Err(err).Str("portal_id", string(ce.Portal.ID)).Msg("Failed to parse portal ID for upgrade command")
|
||||||
|
ce.Reply("Failed to parse portal ID")
|
||||||
|
} else if peerType == ids.PeerTypeChannel {
|
||||||
|
ce.Reply("Only minigroups can be upgraded (this is already a channel/supergroup)")
|
||||||
|
} else if peerType == ids.PeerTypeUser {
|
||||||
|
ce.Reply("Only minigroups can be upgraded (this is direct chat)")
|
||||||
|
} else if resp, err := login.Client.(*TelegramClient).client.API().MessagesMigrateChat(ce.Ctx, chatID); err != nil {
|
||||||
|
ce.Log.Err(err).Int64("chat_id", chatID).Msg("Failed to upgrade chat")
|
||||||
|
ce.Reply("Failed to upgrade chat: %v", err)
|
||||||
|
} else {
|
||||||
|
ce.Log.Trace().Any("response", resp).Msg("Updates from chat upgrade")
|
||||||
|
ce.Log.Info().Int64("old_chat_id", chatID).Msg("Successfully upgraded chat")
|
||||||
|
ce.React("\u2705\ufe0f")
|
||||||
|
err = login.Client.(*TelegramClient).dispatcher.Handle(ce.Ctx, resp)
|
||||||
|
if err != nil {
|
||||||
|
ce.Log.Err(err).Msg("Failed to handle updates from chat upgrade")
|
||||||
|
} else {
|
||||||
|
ce.Log.Debug().Msg("Finished handling updates from chat upgrade")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmdJoin = &commands.FullHandler{
|
||||||
|
Func: fnJoin,
|
||||||
|
Name: "join",
|
||||||
|
Help: commands.HelpMeta{
|
||||||
|
Section: commands.HelpSectionChats,
|
||||||
|
Description: "Join a Telegram group using an invite link.",
|
||||||
|
Args: "[login ID] <invite link>",
|
||||||
|
},
|
||||||
|
RequiresLogin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var usernameLinkRe = regexp.MustCompile(`^(?:(?:https?://)?t(?:elegram)?\.(?:me|dog)/|tg:/{0,2}resolve\?domain=)([a-zA-Z]\w{3,30}[a-zA-Z\d])(?:\?.+)?$`)
|
||||||
|
var inviteLinkRe = regexp.MustCompile(`^(?:(?:https?://)?t(?:elegram)?\.(?:me|dog)/(?:joinchat/|\+)|tg:/{0,2}join\?invite=)([a-zA-Z0-9_-]{8,64})(?:\?.+)?$`)
|
||||||
|
|
||||||
|
func fnJoin(ce *commands.Event) {
|
||||||
|
if len(ce.Args) == 0 || len(ce.Args) > 2 {
|
||||||
|
ce.Reply("Usage: `$cmdprefix join [login ID] <invite link>`")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var login *bridgev2.UserLogin
|
||||||
|
if len(ce.Args) == 2 {
|
||||||
|
targetLogin := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
|
||||||
|
if targetLogin == nil || targetLogin.UserMXID != ce.User.MXID {
|
||||||
|
ce.Reply("No login found with the provided ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
login = targetLogin
|
||||||
|
ce.Args = ce.Args[1:]
|
||||||
|
} else {
|
||||||
|
login = ce.User.GetDefaultLogin()
|
||||||
|
if login == nil {
|
||||||
|
ce.Reply("You're not logged in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := login.Client.(*TelegramClient)
|
||||||
|
var resp tg.UpdatesClass
|
||||||
|
var chatName string
|
||||||
|
if usernameMatch := usernameLinkRe.FindStringSubmatch(ce.Args[0]); usernameMatch != nil {
|
||||||
|
resolve, err := t.client.API().ContactsResolveUsername(ce.Ctx, &tg.ContactsResolveUsernameRequest{Username: usernameMatch[1]})
|
||||||
|
if err != nil {
|
||||||
|
ce.Log.Err(err).Msg("Failed to resolve username from invite link")
|
||||||
|
ce.Reply("Failed to resolve username from invite link: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer, isChannel := resolve.Peer.(*tg.PeerChannel)
|
||||||
|
if !isChannel {
|
||||||
|
ce.Reply("That username does not belong to a channel or supergroup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ch *tg.Channel
|
||||||
|
for _, chat := range resolve.Chats {
|
||||||
|
if chat.GetID() == peer.ChannelID {
|
||||||
|
ch = chat.(*tg.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ch == nil {
|
||||||
|
ce.Reply("Channel information not found in resolve response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatName = ch.Title
|
||||||
|
resp, err = t.client.API().ChannelsJoinChannel(ce.Ctx, ch.AsInput())
|
||||||
|
if err != nil {
|
||||||
|
ce.Log.Err(err).Msg("Failed to join chat with invite link")
|
||||||
|
ce.Reply("Failed to join chat: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if inviteLinkMatch := inviteLinkRe.FindStringSubmatch(ce.Args[0]); inviteLinkMatch != nil {
|
||||||
|
resolve, err := t.client.API().MessagesCheckChatInvite(ce.Ctx, inviteLinkMatch[1])
|
||||||
|
if tgerr.Is(err, tg.ErrInviteHashInvalid) {
|
||||||
|
ce.Reply("Invalid invite link")
|
||||||
|
return
|
||||||
|
} else if tgerr.Is(err, tg.ErrInviteHashExpired) {
|
||||||
|
ce.Reply("Invite link expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch typed := resolve.(type) {
|
||||||
|
case *tg.ChatInviteAlready:
|
||||||
|
titler, ok := typed.Chat.(interface {
|
||||||
|
GetTitle() string
|
||||||
|
})
|
||||||
|
if ok {
|
||||||
|
chatName = titler.GetTitle()
|
||||||
|
} else {
|
||||||
|
chatName = "that chat"
|
||||||
|
}
|
||||||
|
ce.Reply("You're already a member of %s", html.EscapeString(chatName))
|
||||||
|
return
|
||||||
|
case *tg.ChatInvite:
|
||||||
|
chatName = typed.Title
|
||||||
|
default:
|
||||||
|
ce.Log.Warn().Type("resolved_type", typed).Msg("Unexpected response type from MessagesCheckChatInvite")
|
||||||
|
}
|
||||||
|
resp, err = t.client.API().MessagesImportChatInvite(ce.Ctx, inviteLinkMatch[1])
|
||||||
|
if err != nil {
|
||||||
|
ce.Log.Err(err).Msg("Failed to join chat with invite link")
|
||||||
|
ce.Reply("Failed to join chat: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ce.Reply("Invalid invite link format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := t.dispatcher.Handle(ce.Ctx, resp)
|
||||||
|
if err != nil {
|
||||||
|
ce.Log.Err(err).Msg("Failed to handle updates from joining chat with invite link")
|
||||||
|
} else {
|
||||||
|
ce.Log.Debug().Msg("Finished handling updates from joining chat with invite link")
|
||||||
|
}
|
||||||
|
ce.Reply("Successfully joined %s", html.EscapeString(chatName))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmdEmojiPack = &commands.FullHandler{
|
||||||
|
Func: fnEmojiPack,
|
||||||
|
Name: "emoji-pack",
|
||||||
|
Aliases: []string{"pack", "sticker-pack", "emojipack", "stickerpack"},
|
||||||
|
Help: commands.HelpMeta{
|
||||||
|
Section: commands.HelpSectionMisc,
|
||||||
|
Description: "Bridge emoji packs between Matrix and Telegram.",
|
||||||
|
Args: "<upload/download/list/help> [args...]",
|
||||||
|
},
|
||||||
|
RequiresLogin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiPackHelp = `This command can be used to transfer emoji packs between Matrix and Telegram.
|
||||||
|
|
||||||
|
* $cmdprefix emoji-pack upload <telegram shortcode> <room ID> <state key> - Transfer a pack from Matrix to Telegram.
|
||||||
|
* $cmdprefix emoji-pack download <pack shortcode or link> - Transfer a pack from Telegram to Matrix.
|
||||||
|
* $cmdprefix emoji-pack list - List your current emoji packs on Telegram.
|
||||||
|
* $cmdprefix emoji-pack help - Show this help message.`
|
||||||
|
|
||||||
|
func fnEmojiPack(ce *commands.Event) {
|
||||||
|
var login *bridgev2.UserLogin
|
||||||
|
if len(ce.Args) > 0 {
|
||||||
|
targetLogin := ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
|
||||||
|
if targetLogin != nil && targetLogin.UserMXID == ce.User.MXID {
|
||||||
|
ce.Args = ce.Args[1:]
|
||||||
|
login = targetLogin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var command string
|
||||||
|
if len(ce.Args) > 0 {
|
||||||
|
command = strings.ToLower(ce.Args[0])
|
||||||
|
ce.Args = ce.Args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if login == nil {
|
||||||
|
login = ce.User.GetDefaultLogin()
|
||||||
|
if login == nil {
|
||||||
|
ce.Reply("You're not logged in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := login.Client.(*TelegramClient)
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case "help", "":
|
||||||
|
ce.Reply(emojiPackHelp)
|
||||||
|
case "list":
|
||||||
|
client.fnListEmojiPacks(ce)
|
||||||
|
case "upload":
|
||||||
|
client.fnUploadEmojiPack(ce)
|
||||||
|
case "download":
|
||||||
|
client.fnDownloadEmojiPack(ce)
|
||||||
|
default:
|
||||||
|
ce.Reply("Usage: `$cmdprefix emoji-pack <upload/download/list/help> [args...]`")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
up "go.mau.fi/util/configupgrade"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ bridgev2.ConfigValidatingNetwork = (*TelegramConnector)(nil)
|
||||||
|
|
||||||
|
type MemberListConfig struct {
|
||||||
|
MaxInitialSync int `yaml:"max_initial_sync"`
|
||||||
|
SyncBroadcastChannels bool `yaml:"sync_broadcast_channels"`
|
||||||
|
SkipDeleted bool `yaml:"skip_deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c MemberListConfig) NormalizedMaxInitialSync() int {
|
||||||
|
if c.MaxInitialSync < 0 {
|
||||||
|
return 10_000
|
||||||
|
}
|
||||||
|
return c.MaxInitialSync
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceInfo struct {
|
||||||
|
DeviceModel string `yaml:"device_model"`
|
||||||
|
SystemVersion string `yaml:"system_version"`
|
||||||
|
AppVersion string `yaml:"app_version"`
|
||||||
|
SystemLangCode string `yaml:"system_lang_code"`
|
||||||
|
LangCode string `yaml:"lang_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyConfig struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramConfig struct {
|
||||||
|
APIID int `yaml:"api_id"`
|
||||||
|
APIHash string `yaml:"api_hash"`
|
||||||
|
|
||||||
|
DeviceInfo DeviceInfo `yaml:"device_info"`
|
||||||
|
AnimatedSticker media.AnimatedStickerConfig `yaml:"animated_sticker"`
|
||||||
|
MemberList MemberListConfig `yaml:"member_list"`
|
||||||
|
|
||||||
|
Ping struct {
|
||||||
|
IntervalSeconds int `yaml:"interval_seconds"`
|
||||||
|
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||||
|
} `yaml:"ping"`
|
||||||
|
|
||||||
|
ProxyConfig ProxyConfig `yaml:"proxy"`
|
||||||
|
|
||||||
|
Sync struct {
|
||||||
|
UpdateLimit int `yaml:"update_limit"`
|
||||||
|
CreateLimit int `yaml:"create_limit"`
|
||||||
|
LoginLimit int `yaml:"login_sync_limit"`
|
||||||
|
DirectChats bool `yaml:"direct_chats"`
|
||||||
|
} `yaml:"sync"`
|
||||||
|
|
||||||
|
Takeout struct {
|
||||||
|
DialogSync bool `yaml:"dialog_sync"`
|
||||||
|
ForwardBackfill bool `yaml:"forward_backfill"`
|
||||||
|
BackwardBackfill bool `yaml:"backward_backfill"`
|
||||||
|
} `yaml:"takeout"`
|
||||||
|
|
||||||
|
ContactAvatars bool `yaml:"contact_avatars"`
|
||||||
|
ContactNames bool `yaml:"contact_names"`
|
||||||
|
MaxMemberCount int `yaml:"max_member_count"`
|
||||||
|
AlwaysCustomEmojiReaction bool `yaml:"always_custom_emoji_reaction"`
|
||||||
|
SavedMessagesAvatar id.ContentURIString `yaml:"saved_message_avatar"`
|
||||||
|
AlwaysTombstoneOnSupergroupMigration bool `yaml:"always_tombstone_on_supergroup_migration"`
|
||||||
|
ImageAsFilePixels int `yaml:"image_as_file_pixels"`
|
||||||
|
DisableViewOnce bool `yaml:"disable_view_once"`
|
||||||
|
DisplaynameTemplate string `yaml:"displayname_template"`
|
||||||
|
displaynameTemplate *template.Template `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c TelegramConfig) ShouldBridge(participantCount int) bool {
|
||||||
|
return c.MaxMemberCount < 0 || participantCount <= c.MaxMemberCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplaynameParams struct {
|
||||||
|
FullName string
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
Username string
|
||||||
|
UserID int64
|
||||||
|
Deleted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramConfig) FormatDisplayname(firstName, lastName, username string, deleted bool, userID int64) string {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := c.displaynameTemplate.Execute(&buf, DisplaynameParams{
|
||||||
|
FullName: strings.TrimSpace(firstName + " " + lastName),
|
||||||
|
FirstName: firstName,
|
||||||
|
LastName: lastName,
|
||||||
|
Username: username,
|
||||||
|
UserID: userID,
|
||||||
|
Deleted: deleted,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("displayname template is broken: %w", err))
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type umConfig TelegramConfig
|
||||||
|
|
||||||
|
func (c *TelegramConfig) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
err := node.Decode((*umConfig)(c))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.PostProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramConfig) PostProcess() error {
|
||||||
|
var err error
|
||||||
|
c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed example-config.yaml
|
||||||
|
var ExampleConfig string
|
||||||
|
|
||||||
|
func upgradeConfig(helper up.Helper) {
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"app_id"}, []string{"api_id"})
|
||||||
|
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"app_hash"}, []string{"api_hash"})
|
||||||
|
helper.Copy(up.Int, "api_id")
|
||||||
|
helper.Copy(up.Str, "api_hash")
|
||||||
|
helper.Copy(up.Str|up.Null, "device_info", "device_model")
|
||||||
|
helper.Copy(up.Str|up.Null, "device_info", "system_version")
|
||||||
|
helper.Copy(up.Str|up.Null, "device_info", "app_version")
|
||||||
|
helper.Copy(up.Str|up.Null, "device_info", "system_lang_code")
|
||||||
|
helper.Copy(up.Str|up.Null, "device_info", "lang_code")
|
||||||
|
helper.Copy(up.Str, "animated_sticker", "target")
|
||||||
|
helper.Copy(up.Bool, "animated_sticker", "convert_from_webm")
|
||||||
|
helper.Copy(up.Int, "animated_sticker", "args", "width")
|
||||||
|
helper.Copy(up.Int, "animated_sticker", "args", "height")
|
||||||
|
helper.Copy(up.Int, "animated_sticker", "args", "fps")
|
||||||
|
helper.Copy(up.Int, "member_list", "max_initial_sync")
|
||||||
|
helper.Copy(up.Bool, "member_list", "sync_broadcast_channels")
|
||||||
|
helper.Copy(up.Bool, "member_list", "skip_deleted")
|
||||||
|
helper.Copy(up.Int, "ping", "interval_seconds")
|
||||||
|
helper.Copy(up.Int, "ping", "timeout_seconds")
|
||||||
|
helper.Copy(up.Str, "proxy", "type")
|
||||||
|
helper.Copy(up.Str|up.Null, "proxy", "address")
|
||||||
|
helper.Copy(up.Str|up.Null, "proxy", "username")
|
||||||
|
helper.Copy(up.Str|up.Null, "proxy", "password")
|
||||||
|
helper.Copy(up.Int, "sync", "update_limit")
|
||||||
|
helper.Copy(up.Int, "sync", "create_limit")
|
||||||
|
helper.Copy(up.Int, "sync", "login_sync_limit")
|
||||||
|
helper.Copy(up.Bool, "sync", "direct_chats")
|
||||||
|
helper.Copy(up.Bool, "takeout", "dialog_sync")
|
||||||
|
helper.Copy(up.Bool, "takeout", "forward_backfill")
|
||||||
|
helper.Copy(up.Bool, "takeout", "backward_backfill")
|
||||||
|
helper.Copy(up.Bool, "contact_avatars")
|
||||||
|
helper.Copy(up.Bool, "contact_names")
|
||||||
|
helper.Copy(up.Int, "max_member_count")
|
||||||
|
helper.Copy(up.Bool, "always_custom_emoji_reaction")
|
||||||
|
helper.Copy(up.Str, "saved_message_avatar")
|
||||||
|
helper.Copy(up.Bool, "always_tombstone_on_supergroup_migration")
|
||||||
|
helper.Copy(up.Int, "image_as_file_pixels")
|
||||||
|
helper.Copy(up.Bool, "disable_view_once")
|
||||||
|
helper.Copy(up.Str, "displayname_template")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) GetConfig() (example string, data any, upgrader up.Upgrader) {
|
||||||
|
return ExampleConfig, &tc.Config, &up.StructUpgrader{
|
||||||
|
SimpleUpgrader: up.SimpleUpgrader(upgradeConfig),
|
||||||
|
Blocks: [][]string{
|
||||||
|
{"device_info"},
|
||||||
|
{"animated_sticker"},
|
||||||
|
{"member_list"},
|
||||||
|
{"ping"},
|
||||||
|
{"proxy"},
|
||||||
|
{"sync"},
|
||||||
|
{"takeout"},
|
||||||
|
{"max_member_count"},
|
||||||
|
},
|
||||||
|
Base: ExampleConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) ValidateConfig() error {
|
||||||
|
if tc.Config.APIID == 0 {
|
||||||
|
return fmt.Errorf("api_id is required")
|
||||||
|
}
|
||||||
|
if tc.Config.APIHash == "" || tc.Config.APIHash == "tjyd5yge35lbodk1xwzw2jstp90k55qz" {
|
||||||
|
return fmt.Errorf("api_hash is required")
|
||||||
|
}
|
||||||
|
if !slices.Contains([]string{"disable", "gif", "png", "webp", "webm"}, tc.Config.AnimatedSticker.Target) {
|
||||||
|
return fmt.Errorf("unsupported animated sticker target: %s", tc.Config.AnimatedSticker.Target)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2024 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.mau.fi/util/dbutil"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/commands"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TelegramConnector struct {
|
||||||
|
Bridge *bridgev2.Bridge
|
||||||
|
Config TelegramConfig
|
||||||
|
Store *store.Container
|
||||||
|
|
||||||
|
useDirectMedia bool
|
||||||
|
maxFileSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ bridgev2.NetworkConnector = (*TelegramConnector)(nil)
|
||||||
|
var _ bridgev2.MaxFileSizeingNetwork = (*TelegramConnector)(nil)
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) Init(bridge *bridgev2.Bridge) {
|
||||||
|
tc.Store = store.NewStore(bridge.DB.Database, dbutil.ZeroLogger(bridge.Log.With().Str("db_section", "telegram").Logger()))
|
||||||
|
tc.Bridge = bridge
|
||||||
|
tc.Bridge.Commands.(*commands.Processor).AddHandlers(cmdSyncChats, cmdEmojiPack, cmdUpgrade, cmdJoin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) Start(ctx context.Context) error {
|
||||||
|
return tc.Store.Upgrade(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) (err error) {
|
||||||
|
login.Client, err = NewTelegramClient(ctx, tc, login)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) SetMaxFileSize(maxSize int64) {
|
||||||
|
tc.maxFileSize = maxSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) GetName() bridgev2.BridgeName {
|
||||||
|
return bridgev2.BridgeName{
|
||||||
|
DisplayName: "Telegram",
|
||||||
|
NetworkURL: "https://telegram.org/",
|
||||||
|
NetworkIcon: "mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX",
|
||||||
|
NetworkID: "telegram",
|
||||||
|
BeeperBridgeType: "telegram",
|
||||||
|
DefaultPort: 29317,
|
||||||
|
DefaultCommandPrefix: "!tg",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||||
|
// Copyright (C) 2025 Sumner Evans
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/bridgev2"
|
||||||
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||||
|
"maunium.net/go/mautrix/mediaproxy"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/connector/media"
|
||||||
|
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ bridgev2.DirectMediableNetwork = (*TelegramConnector)(nil)
|
||||||
|
|
||||||
|
func (tc *TelegramClient) refetchMedia(ctx context.Context, peerType ids.PeerType, peerID int64, msgID int) (tg.MessageMediaClass, error) {
|
||||||
|
var messages tg.ModifiedMessagesMessages
|
||||||
|
var err error
|
||||||
|
switch peerType {
|
||||||
|
case ids.PeerTypeUser, ids.PeerTypeChat:
|
||||||
|
messages, err = APICallWithUpdates(ctx, tc, func() (tg.ModifiedMessagesMessages, error) {
|
||||||
|
m, err := tc.client.API().MessagesGetMessages(ctx, []tg.InputMessageClass{
|
||||||
|
&tg.InputMessageID{ID: msgID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if messages, ok := m.(tg.ModifiedMessagesMessages); !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported messages type %T", messages)
|
||||||
|
} else {
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
case ids.PeerTypeChannel:
|
||||||
|
var accessHash int64
|
||||||
|
accessHash, err = tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, peerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get channel access hash: %w", err)
|
||||||
|
}
|
||||||
|
messages, err = APICallWithUpdates(ctx, tc, func() (tg.ModifiedMessagesMessages, error) {
|
||||||
|
m, err := tc.client.API().ChannelsGetMessages(ctx, &tg.ChannelsGetMessagesRequest{
|
||||||
|
Channel: &tg.InputChannel{ChannelID: peerID, AccessHash: accessHash},
|
||||||
|
ID: []tg.InputMessageClass{
|
||||||
|
&tg.InputMessageID{ID: msgID},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if messages, ok := m.(tg.ModifiedMessagesMessages); !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported messages type %T", messages)
|
||||||
|
} else {
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown peer type %s", peerType)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get message %d/%d for media info: %w", peerID, msgID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages.GetMessages()) != 1 {
|
||||||
|
return nil, fmt.Errorf("wrong number of messages retrieved %d", len(messages.GetMessages()))
|
||||||
|
} else if msg, ok := messages.GetMessages()[0].(*tg.Message); !ok {
|
||||||
|
return nil, fmt.Errorf("message was of the wrong type %s", messages.GetMessages()[0].TypeName())
|
||||||
|
} else if msg.ID != msgID {
|
||||||
|
return nil, fmt.Errorf("no media found with ID %d", msgID)
|
||||||
|
} else {
|
||||||
|
return msg.Media, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) Download(ctx context.Context, mediaID networkid.MediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
|
||||||
|
info, err := ids.ParseDirectMediaInfo(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log := zerolog.Ctx(ctx).With().
|
||||||
|
Str("component", "direct download").
|
||||||
|
Any("info", info).
|
||||||
|
Logger()
|
||||||
|
ctx = log.WithContext(ctx)
|
||||||
|
log.Info().Any("info", info).Msg("handling direct download")
|
||||||
|
|
||||||
|
// TODO have an in-memory cache for media references?
|
||||||
|
|
||||||
|
userLogin, err := tc.Bridge.GetExistingUserLoginByID(ctx, ids.MakeUserLoginID(info.UserID))
|
||||||
|
if err != nil {
|
||||||
|
if info.PeerType != ids.PeerTypeChannel {
|
||||||
|
return nil, fmt.Errorf("failed to get user login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logins, err := tc.Bridge.GetUserLoginsInPortal(ctx, ids.InternalMakePortalKey(ids.PeerTypeChannel, info.PeerID, 0, ""))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if len(logins) == 0 {
|
||||||
|
return nil, fmt.Errorf("no user logins in the portal (%s %d)", ids.PeerTypeChannel, info.PeerID)
|
||||||
|
}
|
||||||
|
userLogin = logins[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if userLogin == nil || userLogin.Client == nil {
|
||||||
|
log.Error().Msg("User does not have a login or client")
|
||||||
|
return nil, mautrix.MForbidden.WithMessage("User not logged in")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := userLogin.Client.(*TelegramClient)
|
||||||
|
|
||||||
|
if !client.IsLoggedIn() {
|
||||||
|
log.Error().Msg("User is not logged in, returning media proxy error")
|
||||||
|
return nil, mautrix.MForbidden.WithMessage("User not logged in")
|
||||||
|
}
|
||||||
|
|
||||||
|
transferer := media.NewTransferer(client.client.API())
|
||||||
|
var readyTransferer *media.ReadyTransferer
|
||||||
|
|
||||||
|
if info.MessageID > 0 {
|
||||||
|
rawMsgMedia, err := client.refetchMedia(ctx, info.PeerType, info.PeerID, int(info.MessageID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to refetch media message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msgMedia := rawMsgMedia.(type) {
|
||||||
|
case *tg.MessageMediaPhoto:
|
||||||
|
if msgMedia.Video != nil && !info.Thumbnail {
|
||||||
|
log.Debug().
|
||||||
|
Int64("document_id", msgMedia.Video.GetID()).
|
||||||
|
Msg("downloading live photo")
|
||||||
|
readyTransferer = transferer.WithDocument(msgMedia.Video, false)
|
||||||
|
} else {
|
||||||
|
log.Debug().
|
||||||
|
Int64("photo_id", msgMedia.Photo.GetID()).
|
||||||
|
Msg("downloading photo")
|
||||||
|
readyTransferer = transferer.WithPhoto(msgMedia.Photo)
|
||||||
|
}
|
||||||
|
case *tg.MessageMediaDocument:
|
||||||
|
document, ok := msgMedia.Document.(*tg.Document)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown document type %T", msgMedia.Document)
|
||||||
|
}
|
||||||
|
var isSticker bool
|
||||||
|
for _, attr := range document.GetAttributes() {
|
||||||
|
if attr.TypeID() == tg.DocumentAttributeStickerTypeID {
|
||||||
|
transferer = transferer.WithStickerConfig(tc.Config.AnimatedSticker)
|
||||||
|
isSticker = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Int64("document_id", msgMedia.Document.GetID()).
|
||||||
|
Bool("is_sticker", isSticker).
|
||||||
|
Msg("downloading document")
|
||||||
|
readyTransferer = transferer.WithDocument(msgMedia.Document, info.Thumbnail)
|
||||||
|
case *tg.MessageMediaWebPage:
|
||||||
|
webpage, ok := msgMedia.Webpage.(*tg.WebPage)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("not a *tg.WebPage: %T", msgMedia.Webpage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pc, ok := webpage.GetPhoto(); ok && pc.TypeID() == tg.PhotoTypeID {
|
||||||
|
log.Debug().
|
||||||
|
Int64("photo_id", pc.GetID()).
|
||||||
|
Msg("downloading webpage photo")
|
||||||
|
readyTransferer = transferer.WithPhoto(pc)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("no photo found in webpage item")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unhandled media type %T", msgMedia)
|
||||||
|
}
|
||||||
|
} else if info.PeerType == ids.PeerTypeUser {
|
||||||
|
// TODO this needs to be able to use min access hashes
|
||||||
|
readyTransferer, err = transferer.WithUserPhoto(ctx, client.ScopedStore, info.PeerID, info.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create user photo transferer: %w", err)
|
||||||
|
}
|
||||||
|
} else if info.PeerType == ids.PeerTypeChat {
|
||||||
|
readyTransferer = transferer.WithPeerPhoto(&tg.InputPeerChat{ChatID: info.PeerID}, info.ID)
|
||||||
|
} else if info.PeerType == ids.PeerTypeChannel {
|
||||||
|
// TODO min access hashes here too
|
||||||
|
readyTransferer, err = transferer.WithChannelPhoto(ctx, client.ScopedStore, info.PeerID, info.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if info.PeerType == ids.FakePeerTypeEmoji {
|
||||||
|
customEmojiDocuments, err := client.client.API().MessagesGetCustomEmojiDocuments(ctx, []int64{info.ID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get custom emoji documents: %w", err)
|
||||||
|
}
|
||||||
|
if len(customEmojiDocuments) == 0 {
|
||||||
|
return nil, fmt.Errorf("emoji id did not result in a document")
|
||||||
|
}
|
||||||
|
|
||||||
|
readyTransferer = media.NewTransferer(client.client.API()).
|
||||||
|
WithStickerConfig(tc.Config.AnimatedSticker).
|
||||||
|
WithDocument(customEmojiDocuments[0], false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return readyTransferer.ToDirectMediaResponse(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TelegramConnector) SetUseDirectMedia() {
|
||||||
|
tc.useDirectMedia = true
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user