Compare commits
2399 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 | |||
| d5193438de | |||
| 0d22f7a6e3 | |||
| b36f962761 | |||
| ff3da70494 | |||
| 0848938174 | |||
| a82a124b11 | |||
| 1b7a10218a | |||
| 6c8cfc1b26 | |||
| 9b0be2dd55 | |||
| 704e00540e | |||
| 14b105e74f | |||
| f2390c4937 | |||
| 83a9de164e | |||
| a27af08410 | |||
| fd6e22fa5c | |||
| 9d6c3a2ed3 | |||
| 629a406051 | |||
| 1421ae0cce | |||
| 3cca11a997 | |||
| c08659c75a | |||
| d5f6e45363 | |||
| dbfb980bde | |||
| ae334b9a04 | |||
| 55b6773b5e | |||
| a22b83de44 | |||
| c5bec37401 | |||
| aaa4f96805 | |||
| 4736686454 | |||
| f3e1c755eb | |||
| ab098879fd | |||
| 76410ee7cb | |||
| af46aee191 | |||
| e4e100a184 | |||
| 54d7ac5542 | |||
| 54287c344f | |||
| ecdca21e32 | |||
| 2b92483c50 | |||
| ad7b7f5c06 | |||
| 340360e6a0 | |||
| 64d726ec2b | |||
| e4ce73cbba | |||
| 88d50879d5 | |||
| c8e44d4ab4 | |||
| e9348c9550 | |||
| d4b725a508 | |||
| 9830842707 | |||
| 6926bce139 | |||
| 0625b2d661 | |||
| 8aae5beb27 | |||
| 122699593d | |||
| 996e8ab445 | |||
| 23232cf88c | |||
| 87dc1a44b2 | |||
| dfca56b292 | |||
| c4b41f0a5c | |||
| 4d63cd75d4 | |||
| 64391ae20d | |||
| c55967c9f0 | |||
| c2879408cc | |||
| a46cc7a788 | |||
| 9f4f63f084 | |||
| e71f7280b8 | |||
| b4dd05ab04 | |||
| 2aa0ed3825 | |||
| bfaec2eb81 | |||
| 0f1ac98b9f | |||
| 2a65ccc674 | |||
| e16e53c261 | |||
| 96ac0a0b17 | |||
| 6cef4d81c6 | |||
| cea5210290 | |||
| 4cef2be0db | |||
| 34cc810d62 | |||
| bbc7912a49 | |||
| 2b5426fda3 | |||
| d97281bcdc | |||
| 298e326de7 | |||
| 90e7a09b7e | |||
| f6fb37f5da | |||
| ac4d7cc412 | |||
| 94a2344f3b | |||
| 998e2fa19c | |||
| 5082cd1c94 | |||
| 48665acf1d | |||
| bc160e0593 | |||
| 1fd920255f | |||
| c0ceb1b2b0 | |||
| f07009d0d2 | |||
| fa30cb5c1f | |||
| 5d48040eb8 | |||
| f6923a5e1b | |||
| 15fd394d54 | |||
| 1d9455f639 | |||
| 042d89cf65 | |||
| 7515b31164 | |||
| 99f84b5dfe | |||
| 2172587286 | |||
| 193c4409ee | |||
| 74bc89475e | |||
| 7c2e689813 | |||
| 0a171d242f | |||
| 7a4d29e1e4 | |||
| ecf0e262df | |||
| d035e9da73 | |||
| 74f3956608 | |||
| 62b66040e7 | |||
| 8a198e67a8 | |||
| d9e4cc9d4e | |||
| 371c6813de | |||
| 0f8a2e7c51 | |||
| 895f9ac98a | |||
| 86bda1bb45 | |||
| 99f0c02766 | |||
| 4a0d00e74c | |||
| f5c4b477e5 | |||
| b50558a37d | |||
| ad23445b69 | |||
| f473c02bc3 | |||
| f1b52e7465 | |||
| e6e6af0689 | |||
| 7a7c0b780f | |||
| 3775206ab3 | |||
| 1d54d6755c | |||
| 42fc48adfe | |||
| 3068d41570 | |||
| f51d43b999 | |||
| fb43f13ed5 | |||
| 25b1adf626 | |||
| 17aefd02da | |||
| b127afbf9b | |||
| b8f2c9a8f7 | |||
| d466060c44 | |||
| 42056b91c5 | |||
| 68e6a70234 | |||
| 642ea2baae | |||
| 005daa9ee2 | |||
| dad99823fc | |||
| 0d264e09a8 | |||
| 7029102c0f | |||
| 708110eb08 | |||
| c0da861562 | |||
| 844cf14bcd | |||
| fe32475e10 | |||
| f28f5915a4 | |||
| 1aa80c1a8f | |||
| 5d9b94fa5f | |||
| 6ef31599e9 | |||
| e961e0bcc6 | |||
| dc85754b1e | |||
| 04e2c03dae | |||
| 42d54dac5b | |||
| 767a51f994 | |||
| 313b5e5d07 | |||
| 961707dd30 | |||
| 90197f1a40 | |||
| 53a7111550 | |||
| 78d1f92c13 | |||
| 37b13fe31b | |||
| 39c9548983 | |||
| 606686ce84 | |||
| 649f8aa9a4 | |||
| 13db0eea93 | |||
| adbd048108 | |||
| 1639099401 | |||
| 7a373fa556 | |||
| 1f5261ff8f | |||
| 0833850f4f | |||
| 87a715aa10 | |||
| ea209498ba | |||
| 79341b8d28 | |||
| fd763b953d | |||
| 949c380235 | |||
| 81d982d254 | |||
| f7dfbbf3f4 | |||
| 1e0f2c72b5 | |||
| 73e7b8f635 | |||
| 8354bf6bb5 | |||
| db5441c3eb | |||
| bb13813952 | |||
| 2c47cdfac6 | |||
| d9dd304b26 | |||
| 45981b9c77 | |||
| c040c0d59c | |||
| 4c26d7e59a | |||
| ae792a7b33 | |||
| a3ed8dbce3 | |||
| d332a429d6 | |||
| 797ff06d10 | |||
| 193dcc714b | |||
| 445d997be8 | |||
| 8da06c969c | |||
| c87f410d3e | |||
| 824725a698 | |||
| 780edd7e57 | |||
| e231c3ec9a | |||
| f5e3b39105 | |||
| fbb9075bbe | |||
| 07f5348ff0 | |||
| 1ce8f08ff2 | |||
| a652fb1d8c | |||
| 41f2f64322 | |||
| 2eba5f687a | |||
| 423731751d | |||
| 92b86deeba | |||
| b4b1951509 | |||
| cc29aec3f6 | |||
| 65174d9998 | |||
| 4804023acf | |||
| 459128a417 | |||
| d40b0b896b | |||
| 006a5971ea | |||
| 4498ab4721 | |||
| 133e4af712 | |||
| 66d68f6b63 | |||
| a1297e90ce | |||
| c24cd8fbb1 | |||
| 59a0ca33ee | |||
| 502a3599fc | |||
| 6c0399ac7b | |||
| 68a743a563 | |||
| 22f430c340 | |||
| 91ae50911e | |||
| 2bf327dbc5 | |||
| 0e23aafa3d | |||
| 87c87f93ef | |||
| 578b025f17 | |||
| 73de61dabf | |||
| c4b2cf3553 | |||
| 733bbb30c3 | |||
| 88a8404898 | |||
| 54d2b4bba8 | |||
| 4448077d43 | |||
| 209d7cbdcc | |||
| 715b658a3d | |||
| 68648d7b5c | |||
| ad9cd27185 | |||
| ad67996d91 | |||
| b06e7932f0 | |||
| 7837f03532 | |||
| 42e33ab54d | |||
| 7f52238fbb | |||
| ae88aa0553 | |||
| 2d63c5b3ce | |||
| 77c57eb64b | |||
| c98e822e6d | |||
| 85a4982ad9 | |||
| b1c85d5cda | |||
| a469e6ed10 | |||
| 517c7d8b70 | |||
| 8bfb416735 | |||
| 9709768b17 | |||
| f6e3903b45 | |||
| b3082da999 | |||
| 61d9d6890a | |||
| 150321a4d7 | |||
| 3eefbc4e34 | |||
| ee8531143f | |||
| 96d3ca106a | |||
| 8d1de218a1 | |||
| cf9a1f3afb | |||
| 2c68bd7378 | |||
| 6ff89d1fe4 | |||
| 30768d0a06 | |||
| 7004da9268 | |||
| 0e6940eea5 | |||
| 7b4b7509f3 | |||
| 8bbd1f7db1 | |||
| a6f26c16fc | |||
| 13dddb4c10 | |||
| 3aff450bae | |||
| 97957a5731 | |||
| fe00145d1c | |||
| e2ba478095 | |||
| ed8c933772 | |||
| a8322992cc | |||
| e8c0312839 | |||
| e98acf39ae | |||
| 26b8efb1e6 | |||
| 8cce7a7c3a | |||
| 6d648d51da | |||
| 57a00468ba | |||
| cd055e1ba7 | |||
| 021b60a45e | |||
| 172e472221 | |||
| 0f706d511a | |||
| f57d1e7311 | |||
| fd4eb7aa49 | |||
| 4237c36dae | |||
| 633aea45d9 | |||
| 08b6f9dbbf | |||
| a9b362943f | |||
| ead445b81f | |||
| 1bea158191 | |||
| 3a22c1463a | |||
| 3a4628cb6e | |||
| 46cac040c7 | |||
| 64b60559ee | |||
| 56e4f00705 | |||
| da3e37ccc0 | |||
| f37ea89e98 | |||
| a41bf286f2 | |||
| 1f6b9bd04a | |||
| 836232db00 | |||
| 14c2312f9a | |||
| 5fa8dea06f | |||
| d5038e6b98 | |||
| 55046e15b2 | |||
| 8a7ccc0007 | |||
| 1372a16459 | |||
| 6c7f687539 | |||
| fed8adae97 | |||
| 566a2b3892 | |||
| 9e5cb84140 | |||
| 9e5843a0dc | |||
| 2aa48f37a9 | |||
| a1ba82c3b7 | |||
| 6fced123b1 | |||
| 22e4a189eb | |||
| c2e4f5596c | |||
| a26f2c2c36 | |||
| 74a0a3b621 | |||
| 5c46aad0d1 | |||
| 2d2fe86757 | |||
| fb37af12b4 | |||
| f635d87ea3 | |||
| 7c54436dff | |||
| 232ec6ee42 | |||
| 3e62a89b30 | |||
| 2f9cd15013 | |||
| aded9d9210 | |||
| 25252c7b79 | |||
| 8e98ca1ce8 | |||
| bbab5a1376 | |||
| caab071a55 | |||
| cf162e76ec | |||
| c1eb907e8a | |||
| 725b3a1182 | |||
| bc1d0c1d2a | |||
| 74935de459 | |||
| b4d23af05d | |||
| 2d13c30a26 | |||
| 29c71b48de | |||
| 03734a6745 | |||
| e96e1459eb | |||
| 1cf0a6b150 | |||
| 6e1d497e66 | |||
| 12d4025752 | |||
| 05853115c6 | |||
| bbc5f99ae9 | |||
| f9d2d32ef0 | |||
| c21a55ebc7 | |||
| 799dfdb2ac | |||
| 092b80ad02 | |||
| 51b868d9ce | |||
| 5930b2e3bb | |||
| 710976c27e | |||
| 0a6130607d | |||
| f926727a8d | |||
| 5c5915ae66 | |||
| f6b18497b4 | |||
| d8dc7c59f4 | |||
| d21ac58929 | |||
| 7f86ec6c5d | |||
| 1a1d7e6d90 | |||
| 4af4f90a3d | |||
| e003151c7b | |||
| ad11abb56e | |||
| 7d2af0ce75 | |||
| f66c182e82 | |||
| 91f34543dc | |||
| 95fad313c5 | |||
| 1560647a5d | |||
| 457df435ac | |||
| 7b0c58aa27 | |||
| 7dc5384d52 | |||
| c1f582f17a | |||
| eef48a9a56 | |||
| d7e40a86c6 | |||
| 4673546b42 | |||
| 2f75fa1cfe |
@@ -0,0 +1,8 @@
|
||||
.editorconfig
|
||||
*.png
|
||||
*.md
|
||||
logs
|
||||
start
|
||||
config.yaml
|
||||
registration.yaml
|
||||
*.db
|
||||
+9
-1
@@ -8,5 +8,13 @@ charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{yaml,yml,py}]
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
indent_size = 2
|
||||
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
-9
@@ -1,12 +1,16 @@
|
||||
.idea/
|
||||
.idea
|
||||
|
||||
.venv
|
||||
pip-selfcheck.json
|
||||
*.pyc
|
||||
__pycache__
|
||||
*.yaml
|
||||
!.pre-commit-config.yaml
|
||||
!example-config.yaml
|
||||
!provisioning-spec.yaml
|
||||
|
||||
config.yaml
|
||||
registration.yaml
|
||||
*.db
|
||||
*.session
|
||||
*.json
|
||||
!pkg/connector/emojis/unicodemojipack.json
|
||||
*.db*
|
||||
*.log
|
||||
*.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
+20
@@ -0,0 +1,20 @@
|
||||
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 \
|
||||
GID=1337
|
||||
|
||||
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
|
||||
|
||||
COPY --from=builder /build/mautrix-telegram /usr/bin/mautrix-telegram
|
||||
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
||||
VOLUME /data
|
||||
|
||||
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"]
|
||||
@@ -1,23 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
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 General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
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/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -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,12 +1,27 @@
|
||||
# mautrix-telegram
|
||||
A Matrix-Telegram puppeting 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](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
|
||||
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
||||
|
||||
A Telegram chat bridged to the Matrix room will be created once the bridge supports using a bot
|
||||
for unauthenticated users.
|
||||
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
|
||||
|
||||
+38
-75
@@ -1,95 +1,58 @@
|
||||
# Features & roadmap
|
||||
|
||||
* Matrix → Telegram
|
||||
* [x] Plaintext messages
|
||||
* [x] Formatted messages
|
||||
* [ ] Bot commands (!command -> /command)
|
||||
* [x] Mentions
|
||||
* [x] Rich quotes
|
||||
* [ ] Locations (not implemented in Riot)
|
||||
* [x] Images
|
||||
* [x] Files
|
||||
* [x] Message content (text, formatting, files, etc..)
|
||||
* [x] Message redactions
|
||||
* [ ] † Presence
|
||||
* [ ] † Typing notifications
|
||||
* [ ] † Read receipts
|
||||
* [ ] Pinning messages
|
||||
* [x] Power level
|
||||
* [x] Normal chats
|
||||
* [ ] Non-hardcoded PL requirements
|
||||
* [x] Supergroups/channels
|
||||
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
|
||||
* [ ] Membership actions
|
||||
* [x] Inviting
|
||||
* [x] Puppets
|
||||
* [x] Matrix users who have logged into Telegram
|
||||
* [x] Kicking
|
||||
* [ ] Joining
|
||||
* [ ] Chat name as alias
|
||||
* [ ] ‡ Chat invite link as alias
|
||||
* [x] Leaving
|
||||
* [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
|
||||
* [x] Plaintext messages
|
||||
* [x] Formatted messages
|
||||
* [x] Bot commands (/command -> !command)
|
||||
* [x] Mentions
|
||||
* [x] Replies
|
||||
* [x] Forwards
|
||||
* [x] Images
|
||||
* [x] Locations
|
||||
* [x] Stickers
|
||||
* [x] Audio messages
|
||||
* [x] Video messages
|
||||
* [x] Documents
|
||||
* [ ] Message deletions (no way to tell difference between user-specific deletion and global deletion)
|
||||
* [ ] Message edits (not supported in Matrix)
|
||||
* [x] Avatars
|
||||
* [x] Presence
|
||||
* [x] Message reactions
|
||||
* [x] Message edits
|
||||
* [ ] ‡ Message history
|
||||
* [ ] Presence
|
||||
* [x] Typing notifications
|
||||
* [x] Read receipts (private chat only)
|
||||
* [x] Pinning messages
|
||||
* [x] Read receipts
|
||||
* [ ] Pinning messages
|
||||
* [ ] Power level
|
||||
* [ ] Membership actions (invite/kick/join/leave)
|
||||
* [ ] Room metadata changes (name, topic, avatar)
|
||||
* [ ] Initial room metadata
|
||||
* Telegram → Matrix
|
||||
* [x] Message content (text, formatting, files, etc..)
|
||||
* [ ] Advanced message content/media
|
||||
* [x] Custom emojis
|
||||
* [ ] Polls
|
||||
* [ ] Games
|
||||
* [ ] Buttons
|
||||
* [x] Message deletions
|
||||
* [x] Message reactions
|
||||
* [x] Message edits
|
||||
* [x] Message history
|
||||
* [x] Automatically when creating portal
|
||||
* [x] Automatically for missed messages
|
||||
* [x] Avatars
|
||||
* [ ] Presence
|
||||
* [x] Typing notifications
|
||||
* [x] Read receipts (DMs only)
|
||||
* [ ] Pinning messages
|
||||
* [x] Admin/chat creator status
|
||||
* [ ] Supergroup/channel permissions (precise per-user not supported in Matrix)
|
||||
* [x] Membership actions
|
||||
* [x] Inviting
|
||||
* [x] Kicking
|
||||
* [x] Joining/leaving
|
||||
* [x] Supergroup/channel permissions (precise per-user permissions not supported in Matrix)
|
||||
* [x] Membership actions (invite/kick/join/leave)
|
||||
* [ ] Chat metadata changes
|
||||
* [x] Title
|
||||
* [x] Avatar
|
||||
* [ ] † About text
|
||||
* [ ] † Public channel username
|
||||
* [x] Initial chat metadata (about text missing)
|
||||
* [x] User metadata
|
||||
* [x] Initial displayname/avatar
|
||||
* [x] Changes to displayname/avatar
|
||||
* [x] User metadata (displayname/avatar)
|
||||
* [x] Supergroup upgrade
|
||||
* [x] Topics (spaces)
|
||||
* Misc
|
||||
* [x] Automatic portal creation
|
||||
* [x] At startup
|
||||
* [x] When receiving invite or message
|
||||
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
|
||||
* [ ] Option to use bot to relay messages for unauthenticated Matrix users
|
||||
* [ ] Option to use own Matrix account for messages sent from other Telegram clients
|
||||
* [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands)
|
||||
* [x] Logging in and out (`login` + code entering)
|
||||
* [x] Logging out
|
||||
* [ ] Registering (`register`)
|
||||
* [x] Searching for users (`search`)
|
||||
* [x] Starting private chats (`pm`)
|
||||
* [x] Joining chats with invite links (`join`)
|
||||
* [x] Creating a Telegram chat for an existing Matrix room (`create`)
|
||||
* [x] Upgrading the chat of a portal room into a supergroup (`upgrade`)
|
||||
* [x] Change username of supergroup/channel (`groupname`)
|
||||
* [x] Getting the Telegram invite link to a Matrix room (`invitelink`)
|
||||
* Bridge administration
|
||||
* [x] Clean up and forget a portal room (`deleteportal`)
|
||||
* [ ] Setting Matrix-only power levels (`powerlevel`)
|
||||
* [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 (relaybot)
|
||||
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
|
||||
* [ ] ‡ Calls
|
||||
* [ ] ‡ Secret chats (i.e. end-to-bridge encryption on Telegram)
|
||||
|
||||
† 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
|
||||
|
||||
-74
@@ -1,74 +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
|
||||
|
||||
sqlalchemy.url = sqlite:///mautrix-telegram.db
|
||||
|
||||
|
||||
# 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,77 +0,0 @@
|
||||
from __future__ import with_statement
|
||||
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
|
||||
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
|
||||
|
||||
# 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 @@
|
||||
"""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():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -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()
|
||||
}
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [[ -z "$GID" ]]; then
|
||||
GID="$UID"
|
||||
fi
|
||||
|
||||
BINARY_NAME=/usr/bin/mautrix-telegram
|
||||
|
||||
function fixperms {
|
||||
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
|
||||
}
|
||||
|
||||
if [[ ! -f /data/config.yaml ]]; then
|
||||
$BINARY_NAME -c /data/config.yaml -e
|
||||
echo "Didn't find a config file."
|
||||
echo "Copied default config file to /data/config.yaml"
|
||||
echo "Modify that config file to your liking."
|
||||
echo "Start the container again after that to generate the registration file."
|
||||
exit
|
||||
fi
|
||||
|
||||
if [[ ! -f /data/registration.yaml ]]; then
|
||||
$BINARY_NAME -g -c /data/config.yaml -r /data/registration.yaml
|
||||
echo "Didn't find a registration file."
|
||||
echo "Generated one for you."
|
||||
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
|
||||
exit
|
||||
fi
|
||||
|
||||
cd /data
|
||||
fixperms
|
||||
exec su-exec $UID:$GID $BINARY_NAME
|
||||
@@ -1,89 +0,0 @@
|
||||
# Homeserver details
|
||||
homeserver:
|
||||
address: https://matrix.org
|
||||
domain: matrix.org
|
||||
|
||||
# Application service host/registration related details
|
||||
# Changing these values requires regeneration of the registration.
|
||||
appservice:
|
||||
# The protocol the homeserver should use when connecting to this appservice.
|
||||
# Usually "http" or "https".
|
||||
protocol: http
|
||||
|
||||
# The hostname and port where the homeserver can find this appservice.
|
||||
hostname: localhost
|
||||
port: 8080
|
||||
|
||||
# Whether or not to enable debug messages in the console.
|
||||
debug: false
|
||||
|
||||
# The unique ID of this appservice.
|
||||
id: telegram
|
||||
# Username of the appservice bot.
|
||||
bot_username: telegrambot
|
||||
bot_displayname: Telegram bridge bot
|
||||
|
||||
# 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
|
||||
|
||||
# Whether or not to use native Matrix replies. At the time of writing, only riot-web supports
|
||||
# replies and the format of them is subject to change.
|
||||
native_replies: True
|
||||
# If native replies are disabled, should the custom replies contain a link to the message being
|
||||
# replied to?
|
||||
link_in_reply: False
|
||||
# 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
|
||||
|
||||
# The prefix for commands. Only required in non-management rooms.
|
||||
command_prefix: "!tg"
|
||||
|
||||
# Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable.
|
||||
# You can enter a domain without the localpart to allow all users from that homeserver to use the bridge.
|
||||
whitelist:
|
||||
- "internal.example.com"
|
||||
- "@user:public.example.com"
|
||||
|
||||
# Admins can do things like delete portal rooms. Here you must specify the exact MXID, domains
|
||||
# are not accepted.
|
||||
admins:
|
||||
- "@admin:internal.example.com"
|
||||
|
||||
# Telegram config
|
||||
telegram:
|
||||
# Get your own API keys at https://my.telegram.org/apps
|
||||
api_id: 12345
|
||||
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
|
||||
@@ -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,5 +0,0 @@
|
||||
from .appservice import AppService
|
||||
from .errors import MatrixError, MatrixRequestError, IntentError
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||
@@ -1,180 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# matrix-appservice-python - A Matrix Application Service framework written in Python.
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Partly based on github.com/Cadair/python-appservice-framework (MIT license)
|
||||
from functools import partial
|
||||
from contextlib import contextmanager
|
||||
from aiohttp import web
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .intent_api import HTTPAPI
|
||||
from .state_store import StateStore
|
||||
|
||||
|
||||
class AppService:
|
||||
def __init__(self, server, domain, as_token, hs_token, bot_localpart, loop=None, log=None,
|
||||
query_user=None, query_alias=None):
|
||||
self.server = server
|
||||
self.domain = domain
|
||||
self.as_token = as_token
|
||||
self.hs_token = hs_token
|
||||
self.bot_mxid = f"@{bot_localpart}:{domain}"
|
||||
self.state_store = StateStore(autosave_file="mx-state.json")
|
||||
self.state_store.load("mx-state.json")
|
||||
|
||||
self.transactions = []
|
||||
|
||||
self._http_session = None
|
||||
self._intent = None
|
||||
|
||||
self.loop = loop or asyncio.get_event_loop()
|
||||
self.log = (logging.getLogger(log) if isinstance(log, str)
|
||||
else log or logging.getLogger("mautrix_appservice"))
|
||||
|
||||
def default_query_handler(_):
|
||||
return None
|
||||
|
||||
self.query_user = query_user or default_query_handler
|
||||
self.query_alias = query_alias or default_query_handler
|
||||
|
||||
self.event_handlers = []
|
||||
|
||||
self.app = web.Application(loop=self.loop)
|
||||
self.app.router.add_route("PUT", "/transactions/{transaction_id}",
|
||||
self._http_handle_transaction)
|
||||
self.app.router.add_route("GET", "/rooms/{alias}", self._http_query_alias)
|
||||
self.app.router.add_route("GET", "/users/{user_id}", self._http_query_user)
|
||||
|
||||
self.matrix_event_handler(self.update_state_store)
|
||||
|
||||
@property
|
||||
def http_session(self):
|
||||
if self._http_session is None:
|
||||
raise AttributeError("the http_session attribute can only be used "
|
||||
"from within the `AppService.run` context manager")
|
||||
else:
|
||||
return self._http_session
|
||||
|
||||
@property
|
||||
def intent(self):
|
||||
if self._intent is None:
|
||||
raise AttributeError("the intent attribute can only be used from "
|
||||
"within the `AppService.run` context manager")
|
||||
else:
|
||||
return self._intent
|
||||
|
||||
@contextmanager
|
||||
def run(self, host="127.0.0.1", port=8080):
|
||||
self._http_session = aiohttp.ClientSession(loop=self.loop)
|
||||
self._intent = HTTPAPI(base_url=self.server, domain=self.domain, bot_mxid=self.bot_mxid,
|
||||
token=self.as_token, log=self.log, state_store=self.state_store,
|
||||
client_session=self._http_session).bot_intent()
|
||||
|
||||
yield self.loop.create_server(self.app.make_handler(), host, port)
|
||||
|
||||
self._intent = None
|
||||
self._http_session.close()
|
||||
self._http_session = None
|
||||
|
||||
def _check_token(self, request):
|
||||
try:
|
||||
token = request.rel_url.query["access_token"]
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
if token != self.hs_token:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _http_query_user(self, request):
|
||||
if not self._check_token(request):
|
||||
return web.Response(status=401)
|
||||
|
||||
user_id = request.match_info["userId"]
|
||||
|
||||
try:
|
||||
response = await self.query_user(user_id)
|
||||
except Exception:
|
||||
self.log.exception("Exception in user query handler")
|
||||
return web.Response(status=500)
|
||||
|
||||
if not response:
|
||||
return web.Response(status=404)
|
||||
return web.json_response(response)
|
||||
|
||||
async def _http_query_alias(self, request):
|
||||
if not self._check_token(request):
|
||||
return web.Response(status=401)
|
||||
|
||||
alias = request.match_info["alias"]
|
||||
|
||||
try:
|
||||
response = await self.query_alias(alias)
|
||||
except Exception:
|
||||
self.log.exception("Exception in alias query handler")
|
||||
return web.Response(status=500)
|
||||
|
||||
if not response:
|
||||
return web.Response(status=404)
|
||||
return web.json_response(response)
|
||||
|
||||
async def _http_handle_transaction(self, request):
|
||||
if not self._check_token(request):
|
||||
return web.Response(status=401)
|
||||
|
||||
transaction_id = request.match_info["transaction_id"]
|
||||
if transaction_id in self.transactions:
|
||||
return web.Response(status=200)
|
||||
|
||||
json = await request.json()
|
||||
|
||||
try:
|
||||
events = json["events"]
|
||||
except KeyError:
|
||||
return web.Response(status=400)
|
||||
|
||||
for event in events:
|
||||
self.handle_matrix_event(event)
|
||||
|
||||
self.transactions.append(transaction_id)
|
||||
|
||||
return web.json_response({})
|
||||
|
||||
async def update_state_store(self, event):
|
||||
event_type = event["type"]
|
||||
if event_type == "m.room.power_levels":
|
||||
self.state_store.set_power_levels(event["room_id"], event["content"])
|
||||
elif event_type == "m.room.member":
|
||||
self.state_store.set_membership(event["room_id"], event["state_key"],
|
||||
event["content"]["membership"])
|
||||
|
||||
def handle_matrix_event(self, event):
|
||||
async def try_handle(handler):
|
||||
try:
|
||||
await handler(event)
|
||||
except Exception:
|
||||
self.log.exception("Exception in Matrix event handler")
|
||||
|
||||
for handler in self.event_handlers:
|
||||
asyncio.ensure_future(try_handle(handler), loop=self.loop)
|
||||
|
||||
def matrix_event_handler(self, func):
|
||||
self.event_handlers.append(func)
|
||||
return func
|
||||
@@ -1,38 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
class MatrixError(Exception):
|
||||
"""A generic Matrix error. Specific errors will subclass this."""
|
||||
pass
|
||||
|
||||
|
||||
class IntentError(MatrixError):
|
||||
def __init__(self, message, source):
|
||||
super().__init__(message)
|
||||
self.source = source
|
||||
|
||||
|
||||
class MatrixRequestError(MatrixError):
|
||||
""" The home server returned an error response. """
|
||||
|
||||
def __init__(self, code=0, text="", errcode=None, message=None):
|
||||
super().__init__("%d: %s" % (code, text))
|
||||
self.code = code
|
||||
self.text = text
|
||||
self.errcode = errcode
|
||||
self.message = message
|
||||
@@ -1,521 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from urllib.parse import quote
|
||||
from time import time
|
||||
from json.decoder import JSONDecodeError
|
||||
from aiohttp.client_exceptions import ContentTypeError
|
||||
import re
|
||||
import json
|
||||
import magic
|
||||
import asyncio
|
||||
|
||||
from .errors import MatrixError, MatrixRequestError, IntentError
|
||||
|
||||
|
||||
class HTTPAPI:
|
||||
def __init__(self, base_url, domain=None, bot_mxid=None, token=None, identity=None, log=None,
|
||||
state_store=None, client_session=None, child=False):
|
||||
self.base_url = base_url
|
||||
self.token = token
|
||||
self.identity = identity
|
||||
self.validate_cert = True
|
||||
self.session = client_session
|
||||
|
||||
self.domain = domain
|
||||
self.bot_mxid = bot_mxid
|
||||
self._bot_intent = None
|
||||
self.state_store = state_store
|
||||
|
||||
if child:
|
||||
self.log = log
|
||||
else:
|
||||
self.intent_log = log.getChild("intent")
|
||||
self.log = log.getChild("api")
|
||||
self.txn_id = 0
|
||||
self.children = {}
|
||||
|
||||
def user(self, user):
|
||||
try:
|
||||
return self.children[user]
|
||||
except KeyError:
|
||||
child = ChildHTTPAPI(user, self)
|
||||
self.children[user] = child
|
||||
return child
|
||||
|
||||
def bot_intent(self):
|
||||
if self._bot_intent:
|
||||
return self._bot_intent
|
||||
return IntentAPI(self.bot_mxid, self, state_store=self.state_store, log=self.intent_log)
|
||||
|
||||
def intent(self, user):
|
||||
return IntentAPI(user, self.user(user), self.bot_intent(), self.state_store,
|
||||
self.intent_log)
|
||||
|
||||
async def _send(self, method, endpoint, content, query_params, headers):
|
||||
while True:
|
||||
query_params["access_token"] = self.token
|
||||
request = self.session.request(method, endpoint, params=query_params,
|
||||
data=content, headers=headers)
|
||||
async with request as response:
|
||||
if response.status < 200 or response.status >= 300:
|
||||
errcode = message = None
|
||||
try:
|
||||
response_data = await response.json()
|
||||
errcode = response_data["errcode"]
|
||||
message = response_data["error"]
|
||||
except (JSONDecodeError, ContentTypeError, KeyError):
|
||||
pass
|
||||
raise MatrixRequestError(code=response.status, text=await response.text(),
|
||||
errcode=errcode, message=message)
|
||||
|
||||
if response.status == 429:
|
||||
await asyncio.sleep(response.json()["retry_after_ms"] / 1000)
|
||||
else:
|
||||
return await response.json()
|
||||
|
||||
def _log_request(self, method, path, content, query_params):
|
||||
log_content = content if not isinstance(content, bytes) else f"<{len(content)} bytes>"
|
||||
log_content = log_content or "(No content)"
|
||||
query_identity = query_params["user_id"] if "user_id" in query_params else "No identity"
|
||||
self.log.debug("%s %s %s as user %s", method, path, log_content, query_identity)
|
||||
|
||||
def request(self, method, path, content=None, query_params=None, headers=None,
|
||||
api_path="/_matrix/client/r0"):
|
||||
content = content or {}
|
||||
query_params = query_params or {}
|
||||
headers = headers or {}
|
||||
|
||||
method = method.upper()
|
||||
if method not in ["GET", "PUT", "DELETE", "POST"]:
|
||||
raise MatrixError("Unsupported HTTP method: %s" % method)
|
||||
|
||||
if "Content-Type" not in headers:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if headers["Content-Type"] == "application/json":
|
||||
content = json.dumps(content)
|
||||
|
||||
if self.identity:
|
||||
query_params["user_id"] = self.identity
|
||||
|
||||
self._log_request(method, path, content, query_params)
|
||||
|
||||
endpoint = self.base_url + api_path + path
|
||||
return self._send(method, endpoint, content, query_params, headers or {})
|
||||
|
||||
def get_download_url(self, mxcurl):
|
||||
if mxcurl.startswith('mxc://'):
|
||||
return f"{self.base_url}/_matrix/media/r0/download/{mxcurl[6:]}"
|
||||
else:
|
||||
raise ValueError("MXC URL did not begin with 'mxc://'")
|
||||
|
||||
async def get_display_name(self, user_id):
|
||||
content = await self.request("GET", f"/profile/{user_id}/displayname")
|
||||
return content.get('displayname', None)
|
||||
|
||||
async def get_avatar_url(self, user_id):
|
||||
content = await self.request("GET", f"/profile/{user_id}/avatar_url")
|
||||
return content.get('avatar_url', None)
|
||||
|
||||
async def get_room_id(self, room_alias):
|
||||
content = await self.request("GET", f"/directory/room/{quote(room_alias)}")
|
||||
return content.get("room_id", None)
|
||||
|
||||
def set_typing(self, room_id, is_typing=True, timeout=5000, user=None):
|
||||
content = {
|
||||
"typing": is_typing
|
||||
}
|
||||
if is_typing:
|
||||
content["timeout"] = timeout
|
||||
user = user or self.identity
|
||||
return self.request("PUT", f"/rooms/{room_id}/typing/{user}", content)
|
||||
|
||||
|
||||
class ChildHTTPAPI(HTTPAPI):
|
||||
def __init__(self, user, parent):
|
||||
super().__init__(parent.base_url, parent.domain, parent.bot_mxid, parent.token, user,
|
||||
parent.log, parent.state_store, parent.session, child=True)
|
||||
self.parent = parent
|
||||
|
||||
@property
|
||||
def txn_id(self):
|
||||
return self.parent.txn_id
|
||||
|
||||
@txn_id.setter
|
||||
def txn_id(self, value):
|
||||
self.parent.txn_id = value
|
||||
|
||||
|
||||
class IntentAPI:
|
||||
mxid_regex = re.compile("@(.+):(.+)")
|
||||
|
||||
def __init__(self, mxid, client, bot=None, state_store=None, log=None):
|
||||
self.client = client
|
||||
self.bot = bot
|
||||
self.mxid = mxid
|
||||
self.log = log
|
||||
|
||||
results = self.mxid_regex.search(mxid)
|
||||
if not results:
|
||||
raise ValueError("invalid MXID")
|
||||
self.localpart = results.group(1)
|
||||
|
||||
self.state_store = state_store
|
||||
|
||||
def user(self, user):
|
||||
if not self.bot:
|
||||
return self.client.intent(user)
|
||||
else:
|
||||
self.log.warning("Called IntentAPI#user() of child intent object.")
|
||||
return self.bot.client.intent(user)
|
||||
|
||||
# region User actions
|
||||
|
||||
async def get_joined_rooms(self):
|
||||
await self.ensure_registered()
|
||||
response = await self.client.request("GET", "/joined_rooms")
|
||||
return response["joined_rooms"]
|
||||
|
||||
async def set_display_name(self, name):
|
||||
await self.ensure_registered()
|
||||
content = {"displayname": name}
|
||||
return await self.client.request("PUT", f"/profile/{self.mxid}/displayname", content)
|
||||
|
||||
async def set_presence(self, status="online"):
|
||||
await self.ensure_registered()
|
||||
content = {
|
||||
"presence": status
|
||||
}
|
||||
return await self.client.request("PUT", f"/presence/{self.mxid}/status", content)
|
||||
|
||||
async def set_avatar(self, url):
|
||||
await self.ensure_registered()
|
||||
content = {"avatar_url": url}
|
||||
return await self.client.request("PUT", f"/profile/{self.mxid}/avatar_url", content)
|
||||
|
||||
async def upload_file(self, data, mime_type=None):
|
||||
await self.ensure_registered()
|
||||
mime_type = mime_type or magic.from_buffer(data, mime=True)
|
||||
return await self.client.request("POST", "", content=data,
|
||||
headers={"Content-Type": mime_type},
|
||||
api_path="/_matrix/media/r0/upload")
|
||||
|
||||
async def download_file(self, url):
|
||||
await self.ensure_registered()
|
||||
url = self.client.get_download_url(url)
|
||||
async with self.client.session.get(url) as response:
|
||||
return await response.read()
|
||||
|
||||
# endregion
|
||||
# region Room actions
|
||||
|
||||
async def create_room(self, alias=None, is_public=False, name=None, topic=None,
|
||||
is_direct=False, invitees=None, initial_state=None):
|
||||
await self.ensure_registered()
|
||||
content = {
|
||||
"visibility": "public" if is_public else "private",
|
||||
"is_direct": is_direct,
|
||||
}
|
||||
if alias:
|
||||
content["room_alias_name"] = alias
|
||||
if invitees:
|
||||
content["invite"] = invitees
|
||||
if name:
|
||||
content["name"] = name
|
||||
if topic:
|
||||
content["topic"] = topic
|
||||
if initial_state:
|
||||
content["initial_state"] = initial_state
|
||||
|
||||
return await self.client.request("POST", "/createRoom", content)
|
||||
|
||||
def _invite_direct(self, room_id, user_id):
|
||||
content = {"user_id": user_id}
|
||||
return self.client.request("POST", "/rooms/" + room_id + "/invite", content)
|
||||
|
||||
async def invite(self, room_id, user_id, check_cache=False):
|
||||
await self.ensure_joined(room_id)
|
||||
try:
|
||||
ok_states = {"invite", "join"}
|
||||
do_invite = (not check_cache
|
||||
or self.state_store.get_membership(room_id, user_id) not in ok_states)
|
||||
if do_invite:
|
||||
response = await self._invite_direct(room_id, user_id)
|
||||
self.state_store.invited(room_id, user_id)
|
||||
return response
|
||||
except MatrixRequestError as e:
|
||||
if e.errcode != "M_FORBIDDEN":
|
||||
raise IntentError(f"Failed to invite {user_id} to {room_id}", e)
|
||||
if "is already in the room" in e.message:
|
||||
self.state_store.joined(room_id, user_id)
|
||||
|
||||
def set_room_avatar(self, room_id, avatar_url, info=None):
|
||||
content = {
|
||||
"url": avatar_url,
|
||||
}
|
||||
if info:
|
||||
content["info"] = info
|
||||
return self.send_state_event(room_id, "m.room.avatar", content)
|
||||
|
||||
async def add_room_alias(self, room_id, localpart):
|
||||
await self.ensure_registered()
|
||||
content = {"room_id": room_id}
|
||||
alias = f"#{localpart}:{self.client.domain}"
|
||||
return await self.client.request("PUT", f"/directory/room/{quote(alias)}", content)
|
||||
|
||||
async def remove_room_alias(self, localpart):
|
||||
await self.ensure_registered()
|
||||
alias = f"#{localpart}:{self.client.domain}"
|
||||
return await self.client.request("DELETE", f"/directory/room/{quote(alias)}")
|
||||
|
||||
def set_room_name(self, room_id, name):
|
||||
body = {"name": name}
|
||||
return self.send_state_event(room_id, "m.room.name", body)
|
||||
|
||||
async def get_power_levels(self, room_id, ignore_cache=False):
|
||||
await self.ensure_joined(room_id)
|
||||
if not ignore_cache:
|
||||
try:
|
||||
return self.state_store.get_power_levels(room_id)
|
||||
except KeyError:
|
||||
pass
|
||||
levels = await self.client.request("GET",
|
||||
f"/rooms/{quote(room_id)}/state/m.room.power_levels")
|
||||
self.state_store.set_power_levels(room_id, levels)
|
||||
return levels
|
||||
|
||||
async def set_power_levels(self, room_id, content):
|
||||
if "events" not in content:
|
||||
content["events"] = {}
|
||||
response = await self.send_state_event(room_id, "m.room.power_levels", content)
|
||||
self.state_store.set_power_levels(room_id, content)
|
||||
return response
|
||||
|
||||
async def get_pinned_messages(self, room_id):
|
||||
await self.ensure_joined(room_id)
|
||||
response = await self.client.request("GET", f"/rooms/{room_id}/state/m.room.pinned_events")
|
||||
return response["content"]["pinned"]
|
||||
|
||||
def set_pinned_messages(self, room_id, events):
|
||||
return self.send_state_event(room_id, "m.room.pinned_events", {
|
||||
"pinned": events
|
||||
})
|
||||
|
||||
async def pin_message(self, room_id, event_id):
|
||||
events = await self.get_pinned_messages(room_id)
|
||||
if event_id not in events:
|
||||
events.append(event_id)
|
||||
await self.set_pinned_messages(room_id, events)
|
||||
|
||||
async def unpin_message(self, room_id, event_id):
|
||||
events = await self.get_pinned_messages(room_id)
|
||||
if event_id in events:
|
||||
events.remove(event_id)
|
||||
await self.set_pinned_messages(room_id, events)
|
||||
|
||||
async def get_event(self, room_id, event_id):
|
||||
await self.ensure_joined(room_id)
|
||||
return await self.client.request("GET", f"/rooms/{room_id}/event/{event_id}")
|
||||
|
||||
async def set_typing(self, room_id, is_typing=True, timeout=5000):
|
||||
await self.ensure_joined(room_id)
|
||||
content = {
|
||||
"typing": is_typing
|
||||
}
|
||||
if is_typing:
|
||||
content["timeout"] = timeout
|
||||
return await self.client.request("PUT", f"/rooms/{room_id}/typing/{self.mxid}", content)
|
||||
|
||||
async def mark_read(self, room_id, event_id):
|
||||
await self.ensure_joined(room_id)
|
||||
return await self.client.request("POST", f"/rooms/{room_id}/receipt/m.read/{event_id}",
|
||||
content={})
|
||||
|
||||
def send_notice(self, room_id, text, html=None, relates_to=None):
|
||||
return self.send_text(room_id, text, html, "m.notice", relates_to)
|
||||
|
||||
def send_emote(self, room_id, text, html=None, relates_to=None):
|
||||
return self.send_text(room_id, text, html, "m.emote", relates_to)
|
||||
|
||||
def send_image(self, room_id, url, info=None, text=None, relates_to=None):
|
||||
return self.send_file(room_id, url, info or {}, text, "m.image", relates_to)
|
||||
|
||||
def send_file(self, room_id, url, info=None, text=None, file_type="m.file", relates_to=None):
|
||||
return self.send_message(room_id, {
|
||||
"msgtype": file_type,
|
||||
"url": url,
|
||||
"body": text or "Uploaded file",
|
||||
"info": info or {},
|
||||
"m.relates_to": relates_to or None,
|
||||
})
|
||||
|
||||
def send_text(self, room_id, text, html=None, msgtype="m.text", relates_to=None):
|
||||
if html:
|
||||
if not text:
|
||||
text = html
|
||||
return self.send_message(room_id, {
|
||||
"body": text,
|
||||
"msgtype": msgtype,
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": html or text,
|
||||
"m.relates_to": relates_to or None,
|
||||
})
|
||||
else:
|
||||
return self.send_message(room_id, {
|
||||
"body": text,
|
||||
"msgtype": msgtype,
|
||||
"m.relates_to": relates_to or None,
|
||||
})
|
||||
|
||||
def send_message(self, room_id, body):
|
||||
return self.send_event(room_id, "m.room.message", body)
|
||||
|
||||
async def error_and_leave(self, room_id, text, html=None):
|
||||
await self.ensure_joined(room_id)
|
||||
await self.send_notice(room_id, text, html=html)
|
||||
await self.leave_room(room_id)
|
||||
|
||||
def kick(self, room_id, user_id, message):
|
||||
return self.set_membership(room_id, user_id, "leave", message)
|
||||
|
||||
def get_membership(self, room_id, user_id):
|
||||
return self.get_state_event(room_id, "m.room.member", state_key=user_id)
|
||||
|
||||
def set_membership(self, room_id, user_id, membership, reason="", profile=None):
|
||||
body = {
|
||||
"membership": membership,
|
||||
"reason": reason
|
||||
}
|
||||
profile = profile or {}
|
||||
if "displayname" in profile:
|
||||
body["displayname"] = profile["displayname"]
|
||||
if "avatar_url" in profile:
|
||||
body["avatar_url"] = profile["avatar_url"]
|
||||
|
||||
return self.send_state_event(room_id, "m.room.member", body, state_key=user_id)
|
||||
|
||||
@staticmethod
|
||||
def _get_event_url(room_id, event_type, txn_id):
|
||||
return f"/rooms/{quote(room_id)}/send/{quote(event_type)}/{quote(txn_id)}"
|
||||
|
||||
async def send_event(self, room_id, event_type, content, txn_id=None):
|
||||
await self.ensure_joined(room_id)
|
||||
await self._ensure_has_power_level_for(room_id, event_type)
|
||||
|
||||
txn_id = txn_id or str(self.client.txn_id) + str(int(time() * 1000))
|
||||
self.client.txn_id += 1
|
||||
|
||||
url = self._get_event_url(room_id, event_type, txn_id)
|
||||
|
||||
return await self.client.request("PUT", url, content)
|
||||
|
||||
@staticmethod
|
||||
def _get_state_url(room_id, event_type, state_key=""):
|
||||
url = f"/rooms/{quote(room_id)}/state/{quote(event_type)}"
|
||||
if state_key:
|
||||
url += f"/{quote(state_key)}"
|
||||
return url
|
||||
|
||||
async def send_state_event(self, room_id, event_type, content, state_key=""):
|
||||
await self.ensure_joined(room_id)
|
||||
await self._ensure_has_power_level_for(room_id, event_type)
|
||||
url = self._get_state_url(room_id, event_type, state_key)
|
||||
return await self.client.request("PUT", url, content)
|
||||
|
||||
async def get_state_event(self, room_id, event_type, state_key=""):
|
||||
await self.ensure_joined(room_id)
|
||||
url = self._get_state_url(room_id, event_type, state_key)
|
||||
return await self.client.request("GET", url)
|
||||
|
||||
def join_room(self, room_id):
|
||||
return self.ensure_joined(room_id, ignore_cache=True)
|
||||
|
||||
def _join_room_direct(self, room):
|
||||
return self.client.request("POST", f"/join/{quote(room)}")
|
||||
|
||||
def leave_room(self, room_id):
|
||||
try:
|
||||
self.state_store.left(room_id, self.mxid)
|
||||
return self.client.request("POST", f"/rooms/{quote(room_id)}/leave")
|
||||
except MatrixRequestError as e:
|
||||
if "not in room" not in e.message:
|
||||
raise
|
||||
|
||||
def get_room_memberships(self, room_id):
|
||||
return self.client.request("GET", f"/rooms/{quote(room_id)}/members")
|
||||
|
||||
async def get_room_members(self, room_id, allowed_memberships=("join",)):
|
||||
memberships = await self.get_room_memberships(room_id)
|
||||
return [membership["state_key"] for membership in memberships["chunk"] if
|
||||
membership["content"]["membership"] in allowed_memberships]
|
||||
|
||||
async def get_room_state(self, room_id):
|
||||
await self.ensure_joined(room_id)
|
||||
state = await self.client.request("GET", f"/rooms/{quote(room_id)}/state")
|
||||
# TODO update values based on state?
|
||||
return state
|
||||
|
||||
# endregion
|
||||
# region Ensure functions
|
||||
|
||||
async def ensure_joined(self, room_id, ignore_cache=False):
|
||||
if not ignore_cache and self.state_store.is_joined(room_id, self.mxid):
|
||||
return
|
||||
await self.ensure_registered()
|
||||
try:
|
||||
await self._join_room_direct(room_id)
|
||||
self.state_store.joined(room_id, self.mxid)
|
||||
except MatrixRequestError as e:
|
||||
if e.errcode != "M_FORBIDDEN" or not self.bot:
|
||||
raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e)
|
||||
try:
|
||||
await self.bot.invite(room_id, self.mxid)
|
||||
await self._join_room_direct(room_id)
|
||||
self.state_store.joined(room_id, self.mxid)
|
||||
except MatrixRequestError as e2:
|
||||
raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e2)
|
||||
|
||||
def _register(self):
|
||||
content = {"username": self.localpart}
|
||||
query_params = {"kind": "user"}
|
||||
return self.client.request("POST", "/register", content, query_params)
|
||||
|
||||
async def ensure_registered(self):
|
||||
if self.state_store.is_registered(self.mxid):
|
||||
return
|
||||
try:
|
||||
await self._register()
|
||||
except MatrixRequestError as e:
|
||||
if e.errcode != "M_USER_IN_USE":
|
||||
self.log.exception(f"Failed to register {self.mxid}!")
|
||||
# raise IntentError(f"Failed to register {self.mxid}", e)
|
||||
return
|
||||
self.state_store.registered(self.mxid)
|
||||
|
||||
async def _ensure_has_power_level_for(self, room_id, event_type):
|
||||
if not self.state_store.has_power_levels(room_id):
|
||||
await self.get_power_levels(room_id)
|
||||
if self.state_store.has_power_level(room_id, self.mxid, event_type):
|
||||
return
|
||||
elif not self.bot:
|
||||
self.log.warning(
|
||||
f"Power level of {self.mxid} is not enough for {event_type} in {room_id}")
|
||||
# raise IntentError(f"Power level of {self.mxid} is not enough"
|
||||
# + f"for {event_type} in {room_id}")
|
||||
return
|
||||
# TODO implement
|
||||
|
||||
# endregion
|
||||
@@ -1,123 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# matrix-appservice-python - A Matrix Application Service framework written in Python.
|
||||
# Copyright (C) 2018 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import json
|
||||
|
||||
|
||||
class StateStore:
|
||||
def __init__(self, autosave_file=None):
|
||||
self.registrations = set()
|
||||
self.memberships = {}
|
||||
self.power_levels = {}
|
||||
self.autosave_file = autosave_file
|
||||
|
||||
def save(self, file):
|
||||
if isinstance(file, str):
|
||||
output = open(file, "w")
|
||||
else:
|
||||
output = file
|
||||
|
||||
json.dump({
|
||||
"registrations": list(self.registrations),
|
||||
"memberships": self.memberships,
|
||||
"power_levels": self.power_levels,
|
||||
}, output)
|
||||
|
||||
if isinstance(file, str):
|
||||
output.close()
|
||||
|
||||
def load(self, file):
|
||||
if isinstance(file, str):
|
||||
try:
|
||||
input_source = open(file, "r")
|
||||
except FileNotFoundError:
|
||||
return
|
||||
else:
|
||||
input_source = file
|
||||
|
||||
data = json.load(input_source)
|
||||
if "registrations" in data:
|
||||
self.registrations = set(data["registrations"])
|
||||
if "memberships" in data:
|
||||
self.memberships = data["memberships"]
|
||||
if "power_levels" in data:
|
||||
self.power_levels = data["power_levels"]
|
||||
|
||||
if isinstance(file, str):
|
||||
input_source.close()
|
||||
|
||||
def _autosave(self):
|
||||
if self.autosave_file:
|
||||
self.save(self.autosave_file)
|
||||
|
||||
def is_registered(self, user):
|
||||
return user in self.registrations
|
||||
|
||||
def registered(self, user):
|
||||
self.registrations.add(user)
|
||||
self._autosave()
|
||||
|
||||
def get_membership(self, room, user):
|
||||
return self.memberships.get(room, {}).get(user, "left")
|
||||
|
||||
def is_joined(self, room, user):
|
||||
return self.get_membership(room, user) == "join"
|
||||
|
||||
def set_membership(self, room, user, membership):
|
||||
if room not in self.memberships:
|
||||
self.memberships[room] = {}
|
||||
self.memberships[room][user] = membership
|
||||
self._autosave()
|
||||
|
||||
def joined(self, room, user):
|
||||
return self.set_membership(room, user, "join")
|
||||
|
||||
def invited(self, room, user):
|
||||
return self.set_membership(room, user, "invite")
|
||||
|
||||
def left(self, room, user):
|
||||
return self.set_membership(room, user, "left")
|
||||
|
||||
def has_power_levels(self, room):
|
||||
return room in self.power_levels
|
||||
|
||||
def get_power_levels(self, room):
|
||||
return self.power_levels[room]
|
||||
|
||||
def has_power_level(self, room, user, event):
|
||||
room_levels = self.power_levels.get(room, {})
|
||||
required = room_levels.get("events", {}).get(event, 95)
|
||||
has = room_levels.get("users", {}).get(user, 0)
|
||||
return has >= required
|
||||
|
||||
def set_power_level(self, room, user, level):
|
||||
if room not in self.power_levels:
|
||||
self.power_levels[room] = {
|
||||
"users": {},
|
||||
"events": {},
|
||||
}
|
||||
elif "users" not in self.power_levels[room]:
|
||||
self.power_levels[room]["users"] = {}
|
||||
self.power_levels[room]["users"][user] = level
|
||||
self._autosave()
|
||||
|
||||
def set_power_levels(self, room, content):
|
||||
if "events" not in content:
|
||||
content["events"] = {}
|
||||
if "users" not in content:
|
||||
content["users"] = {}
|
||||
self.power_levels[room] = content
|
||||
self._autosave()
|
||||
@@ -1,2 +0,0 @@
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||
@@ -1,93 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import argparse
|
||||
import sys
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
import sqlalchemy as sql
|
||||
from sqlalchemy import orm
|
||||
|
||||
from mautrix_appservice import AppService
|
||||
|
||||
from .base import Base
|
||||
from .config import Config
|
||||
from .matrix import MatrixHandler
|
||||
|
||||
from .db import init as init_db
|
||||
from .user import init as init_user, User
|
||||
from .portal import init as init_portal
|
||||
from .puppet import init as init_puppet
|
||||
|
||||
log = logging.getLogger("mau")
|
||||
time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s")
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(time_formatter)
|
||||
log.addHandler(handler)
|
||||
|
||||
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("-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)
|
||||
config.load()
|
||||
|
||||
if args.generate_registration:
|
||||
config.generate_registration()
|
||||
config.save()
|
||||
print(f"Registration generated and saved to {config.registration_path}")
|
||||
sys.exit(0)
|
||||
|
||||
if config["appservice.debug"]:
|
||||
telethon_log = logging.getLogger("telethon")
|
||||
telethon_log.addHandler(handler)
|
||||
telethon_log.setLevel(logging.DEBUG)
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.debug("Debug messages enabled.")
|
||||
|
||||
db_engine = sql.create_engine(config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
|
||||
db_factory = orm.sessionmaker(bind=db_engine)
|
||||
db_session = orm.scoping.scoped_session(db_factory)
|
||||
Base.metadata.bind = db_engine
|
||||
Base.metadata.create_all()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
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)
|
||||
context = (appserv, db_session, config, loop)
|
||||
|
||||
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
|
||||
MatrixHandler(context)
|
||||
init_db(db_session)
|
||||
init_portal(context)
|
||||
init_puppet(context)
|
||||
startup_actions = init_user(context) + [start]
|
||||
try:
|
||||
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
for user in User.by_tgid.values():
|
||||
user.stop()
|
||||
sys.exit(0)
|
||||
@@ -1,2 +0,0 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
Base = declarative_base()
|
||||
@@ -1,2 +0,0 @@
|
||||
from .handler import command_handler, CommandHandler
|
||||
from . import clean_rooms, auth, meta, telegram
|
||||
@@ -1,119 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import asyncio
|
||||
|
||||
from telethon.errors import *
|
||||
|
||||
from . import command_handler
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
async def ping(evt):
|
||||
if not evt.sender.logged_in:
|
||||
return await evt.reply("You're not logged in.")
|
||||
me = await evt.sender.client.get_me()
|
||||
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, management_only=True)
|
||||
def register(evt):
|
||||
return evt.reply("Not yet implemented.")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, management_only=True)
|
||||
async def login(evt):
|
||||
if evt.sender.logged_in:
|
||||
return await evt.reply("You are already logged in.")
|
||||
elif len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp login <phone number>`")
|
||||
phone_number = evt.args[0]
|
||||
await evt.sender.client.sign_in(phone_number)
|
||||
evt.sender.command_status = {
|
||||
"next": enter_code,
|
||||
"action": "Login",
|
||||
}
|
||||
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
async def enter_code(evt):
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
|
||||
|
||||
try:
|
||||
user = await evt.sender.client.sign_in(code=evt.args[0])
|
||||
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 PhoneNumberUnoccupiedError:
|
||||
return await evt.reply("That phone number has not been registered."
|
||||
"Please register with `$cmdprefix+sp register <phone>`.")
|
||||
except PhoneCodeExpiredError:
|
||||
return await evt.reply(
|
||||
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
|
||||
except PhoneCodeInvalidError:
|
||||
return await evt.reply("Invalid phone code.")
|
||||
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 block is usually applied for around a day.")
|
||||
except PhoneNumberBannedError:
|
||||
return await evt.reply("Your phone number has been banned from Telegram.")
|
||||
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.")
|
||||
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):
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
|
||||
|
||||
try:
|
||||
user = await evt.sender.client.sign_in(password=evt.args[0])
|
||||
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 PasswordHashInvalidError:
|
||||
return await evt.reply("Incorrect password.")
|
||||
except Exception:
|
||||
evt.log.exception("Error sending password")
|
||||
return await evt.reply("Unhandled exception while sending password. "
|
||||
"Check console for more details.")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
async def logout(evt):
|
||||
if not evt.sender.logged_in:
|
||||
return await evt.reply("You're not logged in.")
|
||||
if await evt.sender.log_out():
|
||||
return await evt.reply("Logged out successfully.")
|
||||
return await evt.reply("Failed to log out.")
|
||||
@@ -1,170 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
|
||||
from . import command_handler
|
||||
from .. import puppet as pu, portal as po
|
||||
|
||||
|
||||
async def _find_rooms(intent):
|
||||
management_rooms = []
|
||||
unidentified_rooms = []
|
||||
portals = []
|
||||
empty_portals = []
|
||||
|
||||
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, name="clean-rooms")
|
||||
async def clean_rooms(evt):
|
||||
if not evt.is_management:
|
||||
return await evt.reply("`clean-rooms` is a particularly spammy command. Please don't "
|
||||
"run it in non-management rooms.")
|
||||
|
||||
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 cleanrooms` "
|
||||
"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, unidentified_rooms, portals, empty_portals):
|
||||
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 += 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 = 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, type="Room")
|
||||
cleaned += 1
|
||||
evt.sender.command_status = None
|
||||
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
|
||||
else:
|
||||
await evt.reply("Room cleaning cancelled.")
|
||||
@@ -1,115 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import markdown
|
||||
import logging
|
||||
|
||||
from telethon.errors import FloodWaitError
|
||||
|
||||
command_handlers = {}
|
||||
|
||||
|
||||
def command_handler(needs_auth=True, management_only=False, needs_admin=False, name=None):
|
||||
def decorator(func):
|
||||
def wrapper(evt):
|
||||
if management_only and not evt.is_management:
|
||||
return evt.reply(f"`{evt.command}` is a restricted command:"
|
||||
+ "you may only run it in management rooms.")
|
||||
elif needs_auth and not evt.sender.logged_in:
|
||||
return evt.reply("This command requires you to be logged in.")
|
||||
elif needs_admin and not evt.sender.is_admin:
|
||||
return evt.reply("This is command requires administrator privileges.")
|
||||
return func(evt)
|
||||
|
||||
command_handlers[name or func.__name__.replace("_", "-")] = wrapper
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class CommandEvent:
|
||||
def __init__(self, handler, room, sender, command, args, is_management, is_portal):
|
||||
self.az = handler.az
|
||||
self.log = handler.log
|
||||
self.loop = handler.loop
|
||||
self.command_prefix = handler.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, allow_html=False, render_markdown=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)
|
||||
|
||||
|
||||
def format_duration(seconds):
|
||||
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)
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
log = logging.getLogger("mau.commands")
|
||||
|
||||
def __init__(self, context):
|
||||
self.az, self.db, self.config, self.loop = context
|
||||
self.command_prefix = self.config["bridge.command_prefix"]
|
||||
|
||||
# region Utility functions for handling commands
|
||||
|
||||
async def handle(self, room, sender, command, args, is_management, is_portal):
|
||||
evt = CommandEvent(self, room, sender, command, args,
|
||||
is_management, is_portal)
|
||||
command = command.lower()
|
||||
try:
|
||||
command = command_handlers[command]
|
||||
except KeyError:
|
||||
if sender.command_status and "next" in sender.command_status:
|
||||
args.insert(0, command)
|
||||
evt.command = ""
|
||||
command = sender.command_status["next"]
|
||||
else:
|
||||
command = command_handlers["unknown-command"]
|
||||
try:
|
||||
await command(evt)
|
||||
except FloodWaitError as e:
|
||||
return evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
|
||||
except Exception:
|
||||
self.log.exception(f"Fatal error handling command "
|
||||
+ f"{evt.command} {' '.join(args)} from {sender.mxid}")
|
||||
return evt.reply("Fatal error while handling command. Check logs for more details.")
|
||||
@@ -1,76 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from . import command_handler
|
||||
|
||||
|
||||
@command_handler()
|
||||
def cancel(evt):
|
||||
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()
|
||||
def unknown_command(evt):
|
||||
return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
|
||||
|
||||
|
||||
@command_handler()
|
||||
def help(evt):
|
||||
if evt.is_management:
|
||||
management_status = ("This is a management room: prefixing commands "
|
||||
"with `$cmdprefix` is not required.\n")
|
||||
elif evt.is_portal:
|
||||
management_status = ("**This is a portal room**: you must always "
|
||||
"prefix commands with `$cmdprefix`.\n"
|
||||
"Management commands will not be sent to Telegram.")
|
||||
else:
|
||||
management_status = ("**This is not a management room**: you must "
|
||||
"prefix commands with `$cmdprefix`.\n")
|
||||
help = """\n
|
||||
#### Generic bridge commands
|
||||
**help** - Show this help message.
|
||||
**cancel** - Cancel an ongoing action (such as login).
|
||||
|
||||
#### Authentication
|
||||
**login** <_phone_> - Request an authentication code.
|
||||
**logout** - Log out from Telegram.
|
||||
**ping** - Check if you're logged into Telegram.
|
||||
|
||||
#### Initiating chats
|
||||
**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
|
||||
**pm** <_identifier_> - Open a private chat with the given Telegram user. The
|
||||
identifier is either the internal user ID, the username or
|
||||
the phone number.
|
||||
**join** <_link_> - Join a chat with an invite link.
|
||||
**create** [_type_] - Create a Telegram chat of the given type for the current
|
||||
Matrix room. The type is either `group`, `supergroup` or
|
||||
`channel` (defaults to `group`).
|
||||
|
||||
#### Portal management
|
||||
**upgrade** - Upgrade a normal Telegram group to a supergroup.
|
||||
**invite-link** - Get a Telegram invite link to the current chat.
|
||||
**delete-portal** - Forget the current portal room. Only works for group chats; to delete
|
||||
a private chat portal, simply leave the room.
|
||||
**group-name** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash
|
||||
(`-`) as the name.
|
||||
**clean-rooms** - Clean up unused portal/management rooms.
|
||||
"""
|
||||
return evt.reply(management_status + help)
|
||||
@@ -1,261 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://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
|
||||
|
||||
|
||||
@command_handler()
|
||||
async def search(evt):
|
||||
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()
|
||||
async def pm(evt):
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
|
||||
|
||||
user = await evt.sender.client.get_entity(evt.args[0])
|
||||
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(
|
||||
f"Created private chat room with {pu.Puppet.get_displayname(user, False)}")
|
||||
|
||||
|
||||
@command_handler()
|
||||
async def invite_link(evt):
|
||||
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.")
|
||||
|
||||
|
||||
@command_handler(needs_admin=True)
|
||||
async def delete_portal(evt):
|
||||
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.")
|
||||
|
||||
async def post_confirm(confirm):
|
||||
evt.sender.command_status = None
|
||||
if len(confirm.args) > 0 and confirm.args[0] == "confirm-delete":
|
||||
await portal.cleanup_and_delete()
|
||||
if confirm.room_id != room_id:
|
||||
return await confirm.reply("Portal successfully deleted.")
|
||||
else:
|
||||
return await confirm.reply("Portal deletion cancelled.")
|
||||
|
||||
evt.sender.command_status = {
|
||||
"next": post_confirm,
|
||||
"action": "Portal deletion",
|
||||
}
|
||||
return await evt.reply("Please confirm deletion of portal "
|
||||
+ f"[{room_id}](https://matrix.to/#/{room_id}) "
|
||||
+ f"to Telegram chat \"{portal.title}\" "
|
||||
+ "by typing `$cmdprefix+sp confirm-delete`")
|
||||
|
||||
|
||||
@command_handler()
|
||||
async def join(evt):
|
||||
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.")
|
||||
arg = arg.group(1)
|
||||
if arg.startswith("joinchat/"):
|
||||
invite_hash = arg[len("joinchat/"):]
|
||||
try:
|
||||
await evt.sender.client(CheckChatInviteRequest(invite_hash))
|
||||
except InviteHashInvalidError:
|
||||
return await evt.reply("Invalid invite link.")
|
||||
except InviteHashExpiredError:
|
||||
return await evt.reply("Invite link expired.")
|
||||
try:
|
||||
updates = evt.sender.client(ImportChatInviteRequest(invite_hash))
|
||||
except UserAlreadyParticipantError:
|
||||
return await evt.reply("You are already in that chat.")
|
||||
else:
|
||||
channel = await evt.sender.client.get_entity(arg)
|
||||
if not channel:
|
||||
return await evt.reply("Channel/supergroup not found.")
|
||||
updates = await evt.sender.client(JoinChannelRequest(channel))
|
||||
for chat in updates.chats:
|
||||
portal = po.Portal.get_by_entity(chat)
|
||||
if portal.mxid:
|
||||
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
|
||||
return await evt.reply(f"Created room for {portal.title}")
|
||||
else:
|
||||
await portal.invite_matrix([evt.sender.mxid])
|
||||
return await evt.reply(f"Invited you to portal of {portal.title}")
|
||||
|
||||
|
||||
@command_handler()
|
||||
async def create(evt):
|
||||
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.")
|
||||
|
||||
state = await evt.az.intent.get_room_state(evt.room_id)
|
||||
title = None
|
||||
about = None
|
||||
levels = None
|
||||
for event in state:
|
||||
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"]
|
||||
if not title:
|
||||
return await evt.reply("Please set a title before creating a Telegram chat.")
|
||||
elif (not levels or not levels["users"] or evt.az.intent.mxid not in levels["users"] or
|
||||
levels["users"][evt.az.intent.mxid] < 100):
|
||||
return await evt.reply(f"Please give "
|
||||
+ f"[the bridge bot](https://matrix.to/#/{evt.az.intent.mxid})"
|
||||
+ f" a power level of 100 before creating a Telegram chat.")
|
||||
else:
|
||||
for user, level in levels["users"].items():
|
||||
if level >= 100 and user != evt.az.intent.mxid:
|
||||
return await evt.reply(
|
||||
f"Please make sure only the bridge bot has power level above"
|
||||
+ f"99 before creating a Telegram chat.\n\n"
|
||||
+ f"Use power level 95 instead of 100 for admins.")
|
||||
|
||||
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:
|
||||
return await evt.reply(e.args[0])
|
||||
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
|
||||
|
||||
|
||||
@command_handler()
|
||||
async def upgrade(evt):
|
||||
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()
|
||||
async def group_name(evt):
|
||||
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")
|
||||
@@ -1,115 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from ruamel.yaml import YAML
|
||||
import random
|
||||
import string
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
|
||||
class DictWithRecursion:
|
||||
def __init__(self, data=None):
|
||||
self._data = data or {}
|
||||
|
||||
def _recursive_get(self, data, key, default_value):
|
||||
if '.' in key:
|
||||
key, next_key = key.split('.', 1)
|
||||
next_data = data.get(key, {})
|
||||
return self._recursive_get(next_data, next_key, default_value)
|
||||
return data.get(key, default_value)
|
||||
|
||||
def get(self, key, default_value, allow_recursion=True):
|
||||
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):
|
||||
return self.get(key, None)
|
||||
|
||||
def _recursive_set(self, data, key, value):
|
||||
if '.' in key:
|
||||
key, next_key = key.split('.', 1)
|
||||
if key not in data:
|
||||
data[key] = {}
|
||||
next_data = data.get(key, {})
|
||||
self._recursive_set(next_data, next_key, value)
|
||||
return
|
||||
data[key] = value
|
||||
|
||||
def set(self, key, value, allow_recursion=True):
|
||||
if allow_recursion and '.' in key:
|
||||
self._recursive_set(self._data, key, value)
|
||||
return
|
||||
self._data[key] = value
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.set(key, value)
|
||||
|
||||
|
||||
class Config(DictWithRecursion):
|
||||
def __init__(self, path, registration_path):
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.registration_path = registration_path
|
||||
self._registration = None
|
||||
|
||||
def load(self):
|
||||
with open(self.path, 'r') as stream:
|
||||
self._data = yaml.load(stream)
|
||||
|
||||
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():
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
|
||||
|
||||
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())
|
||||
|
||||
url = (f"{self['appservice.protocol']}://"
|
||||
+ f"{self['appservice.hostname']}:{self['appservice.port']}")
|
||||
self._registration = {
|
||||
"id": self.get("appservice.id", "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": url,
|
||||
"sender_localpart": self["appservice.bot_username"],
|
||||
"rate_limited": False
|
||||
}
|
||||
@@ -1,103 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from sqlalchemy import Column, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Portal(Base):
|
||||
query = None
|
||||
__tablename__ = "portal"
|
||||
|
||||
# Telegram chat information
|
||||
tgid = Column(Integer, primary_key=True)
|
||||
tg_receiver = Column(Integer, primary_key=True)
|
||||
peer_type = Column(String)
|
||||
|
||||
# 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
|
||||
__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
|
||||
__tablename__ = "user_portal"
|
||||
|
||||
user = Column(Integer, ForeignKey("user.tgid"), 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")),)
|
||||
|
||||
|
||||
class User(Base):
|
||||
query = None
|
||||
__tablename__ = "user"
|
||||
|
||||
mxid = Column(String, primary_key=True)
|
||||
tgid = Column(Integer, nullable=True)
|
||||
tg_username = Column(String, nullable=True)
|
||||
saved_contacts = Column(Integer, default=0)
|
||||
contacts = relationship("Contact", uselist=True,
|
||||
cascade="save-update, merge, delete, delete-orphan")
|
||||
portals = relationship("Portal", secondary="user_portal", single_parent=True,
|
||||
cascade="save-update, merge, delete, delete-orphan")
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
query = None
|
||||
__tablename__ = "contact"
|
||||
|
||||
user = Column("user", Integer, ForeignKey("user.tgid"), primary_key=True)
|
||||
contact = Column("contact", Integer, ForeignKey("puppet.id"), primary_key=True)
|
||||
|
||||
|
||||
class Puppet(Base):
|
||||
query = None
|
||||
__tablename__ = "puppet"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
displayname = Column(String, nullable=True)
|
||||
username = Column(String, nullable=True)
|
||||
photo_id = Column(String, nullable=True)
|
||||
|
||||
|
||||
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()
|
||||
@@ -1,380 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from html import escape, unescape
|
||||
from html.parser import HTMLParser
|
||||
from collections import deque
|
||||
import re
|
||||
import logging
|
||||
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
|
||||
from telethon.tl.types import *
|
||||
|
||||
from . import user as u, puppet as p
|
||||
from .db import Message as DBMessage
|
||||
|
||||
log = logging.getLogger("mau.formatter")
|
||||
|
||||
# TEXT LEN EXPLANATION:
|
||||
# Telegram formatting counts two bytes in an UTF-16 string as one character.
|
||||
#
|
||||
# For Telegram -> Matrix formatting, we get the same counting mechanism by encoding the input
|
||||
# text as UTF-16 Little Endian and doubling all the offsets and lengths given by Telegram. With
|
||||
# those doubled values, we process the input entities and text. The text is converted back to
|
||||
# native str format before it's inserted into the output HTML.
|
||||
#
|
||||
# For Matrix -> Telegram formatting, do the same input encoding, but divide the length by two
|
||||
# instead of multiplying when generating the lengths and offsets of Telegram entities.
|
||||
#
|
||||
# The endianness doesn't matter, but it has to be specified to avoid the two BOM bits messing
|
||||
# everything up.
|
||||
TEMP_ENC = "utf-16-le"
|
||||
|
||||
|
||||
# region Matrix to Telegram
|
||||
|
||||
|
||||
class MatrixParser(HTMLParser):
|
||||
mention_regex = re.compile("https://matrix.to/#/(@.+)")
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.text = ""
|
||||
self.entities = []
|
||||
self._building_entities = {}
|
||||
self._list_counter = 0
|
||||
self._open_tags = deque()
|
||||
self._open_tags_meta = deque()
|
||||
self._previous_ended_line = True
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self._open_tags.appendleft(tag)
|
||||
self._open_tags_meta.appendleft(0)
|
||||
attrs = dict(attrs)
|
||||
entity_type = None
|
||||
args = {}
|
||||
if tag == "strong" or tag == "b":
|
||||
entity_type = MessageEntityBold
|
||||
elif tag == "em" or tag == "i":
|
||||
entity_type = MessageEntityItalic
|
||||
elif tag == "code":
|
||||
try:
|
||||
pre = self._building_entities["pre"]
|
||||
try:
|
||||
pre.language = attrs["class"][len("language-"):]
|
||||
except KeyError:
|
||||
pass
|
||||
except KeyError:
|
||||
entity_type = MessageEntityCode
|
||||
elif tag == "pre":
|
||||
entity_type = MessageEntityPre
|
||||
args["language"] = ""
|
||||
elif tag == "a":
|
||||
try:
|
||||
url = attrs["href"]
|
||||
except KeyError:
|
||||
return
|
||||
mention = self.mention_regex.search(url)
|
||||
if mention:
|
||||
mxid = mention.group(1)
|
||||
user = p.Puppet.get_by_mxid(mxid, create=False)
|
||||
if not user:
|
||||
user = u.User.get_by_mxid(mxid, create=False)
|
||||
if not user:
|
||||
return
|
||||
if user.username:
|
||||
entity_type = MessageEntityMention
|
||||
url = f"@{user.username}"
|
||||
else:
|
||||
entity_type = MessageEntityMentionName
|
||||
args["user_id"] = user.tgid
|
||||
elif url.startswith("mailto:"):
|
||||
url = url[len("mailto:"):]
|
||||
entity_type = MessageEntityEmail
|
||||
else:
|
||||
if self.get_starttag_text() == url:
|
||||
entity_type = MessageEntityUrl
|
||||
else:
|
||||
entity_type = MessageEntityTextUrl
|
||||
args["url"] = url
|
||||
url = None
|
||||
self._open_tags_meta.popleft()
|
||||
self._open_tags_meta.appendleft(url)
|
||||
|
||||
if entity_type and tag not in self._building_entities:
|
||||
# See "TEXT LEN EXPLANATION" near start of file
|
||||
offset = int(len(self.text.encode(TEMP_ENC)) / 2)
|
||||
self._building_entities[tag] = entity_type(offset=offset, length=0, **args)
|
||||
|
||||
def _list_depth(self):
|
||||
depth = 0
|
||||
for tag in self._open_tags:
|
||||
if tag == "ol" or tag == "ul":
|
||||
depth += 1
|
||||
return depth
|
||||
|
||||
def handle_data(self, text):
|
||||
text = unescape(text)
|
||||
previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ""
|
||||
list_format_offset = 0
|
||||
if previous_tag == "a":
|
||||
url = self._open_tags_meta[0]
|
||||
if url:
|
||||
text = url
|
||||
elif len(self._open_tags) > 1 and self._previous_ended_line and previous_tag == "li":
|
||||
list_type = self._open_tags[1]
|
||||
indent = (self._list_depth() - 1) * 4 * " "
|
||||
text = text.strip("\n")
|
||||
if len(text) == 0:
|
||||
return
|
||||
elif list_type == "ul":
|
||||
text = f"{indent}* {text}"
|
||||
list_format_offset = len(indent) + 2
|
||||
elif list_type == "ol":
|
||||
n = self._open_tags_meta[1]
|
||||
n += 1
|
||||
self._open_tags_meta[1] = n
|
||||
text = f"{indent}{n}. {text}"
|
||||
list_format_offset = len(indent) + 3
|
||||
for tag, entity in self._building_entities.items():
|
||||
# See "TEXT LEN EXPLANATION" near start of file
|
||||
entity.length += int(len(text.strip("\n").encode(TEMP_ENC)) / 2)
|
||||
entity.offset += list_format_offset
|
||||
|
||||
if text.endswith("\n"):
|
||||
self._previous_ended_line = True
|
||||
else:
|
||||
self._previous_ended_line = False
|
||||
|
||||
self.text += text
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
try:
|
||||
self._open_tags.popleft()
|
||||
self._open_tags_meta.popleft()
|
||||
except IndexError:
|
||||
pass
|
||||
if (tag == "ul" or tag == "ol") and self.text.endswith("\n"):
|
||||
self.text = self.text[:-1]
|
||||
entity = self._building_entities.pop(tag, None)
|
||||
if entity:
|
||||
self.entities.append(entity)
|
||||
|
||||
|
||||
def matrix_to_telegram(html):
|
||||
try:
|
||||
parser = MatrixParser()
|
||||
parser.feed(html)
|
||||
return parser.text, parser.entities
|
||||
except Exception:
|
||||
log.exception("Failed to convert Matrix format:\nhtml=%s", html)
|
||||
|
||||
|
||||
def matrix_reply_to_telegram(content, tg_space, room_id=None):
|
||||
try:
|
||||
reply = content["m.relates_to"]["m.in_reply_to"]
|
||||
room_id = room_id or reply["room_id"]
|
||||
event_id = reply["event_id"]
|
||||
message = DBMessage.query.filter(DBMessage.mxid == event_id and
|
||||
DBMessage.tg_space == tg_space and
|
||||
DBMessage.mx_room == room_id).one_or_none()
|
||||
if message:
|
||||
return message.tgid
|
||||
except KeyError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# endregion
|
||||
# region Telegram to Matrix
|
||||
|
||||
def telegram_reply_to_matrix(evt, source):
|
||||
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 telegram_event_to_matrix(evt, source, native_replies=False, message_link_in_reply=False,
|
||||
main_intent=None, reply_text="Reply"):
|
||||
text = evt.message
|
||||
html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None
|
||||
relates_to = {}
|
||||
|
||||
if evt.fwd_from:
|
||||
if not html:
|
||||
html = escape(text)
|
||||
from_id = evt.fwd_from.from_id
|
||||
user = u.User.get_by_tgid(from_id)
|
||||
if user:
|
||||
fwd_from = f"<a href='https://matrix.to/#/{user.mxid}'>{user.mxid}</a>"
|
||||
else:
|
||||
puppet = p.Puppet.get(from_id, create=False)
|
||||
if puppet and puppet.displayname:
|
||||
fwd_from = f"<a href='https://matrix.to/#/{puppet.mxid}'>{puppet.displayname}</a>"
|
||||
else:
|
||||
user = await source.client.get_entity(from_id)
|
||||
if user:
|
||||
fwd_from = p.Puppet.get_displayname(user, format=False)
|
||||
else:
|
||||
fwd_from = None
|
||||
if not fwd_from:
|
||||
fwd_from = "Unknown user"
|
||||
html = (f"Forwarded message from <b>{fwd_from}</b><br/>"
|
||||
+ f"<blockquote>{html}</blockquote>")
|
||||
|
||||
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:
|
||||
if native_replies:
|
||||
relates_to["m.in_reply_to"] = {
|
||||
"event_id": msg.mxid,
|
||||
"room_id": msg.mx_room,
|
||||
}
|
||||
if reply_text == "Edit":
|
||||
html = "<u>Edit:</u> " + (html or escape(text))
|
||||
else:
|
||||
try:
|
||||
event = await main_intent.get_event(msg.mx_room, msg.mxid)
|
||||
content = event["content"]
|
||||
body = (content["formatted_body"]
|
||||
if "formatted_body" in content
|
||||
else content["body"])
|
||||
sender = event['sender']
|
||||
puppet = p.Puppet.get_by_mxid(sender, create=False)
|
||||
displayname = puppet.displayname if puppet else sender
|
||||
reply_to_user = f"<a href='https://matrix.to/#/{sender}'>{displayname}</a>"
|
||||
reply_to_msg = (("<a href='https://matrix.to/#/"
|
||||
+ f"{msg.mx_room}/{msg.mxid}'>{reply_text}</a>")
|
||||
if message_link_in_reply else "Reply")
|
||||
quote = f"{reply_to_msg} to {reply_to_user}<blockquote>{body}</blockquote>"
|
||||
except (ValueError, KeyError, MatrixRequestError):
|
||||
quote = "{reply_text} to unknown user <em>(Failed to fetch message)</em>:<br/>"
|
||||
if html:
|
||||
html = quote + html
|
||||
else:
|
||||
html = quote + escape(text)
|
||||
|
||||
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>"
|
||||
|
||||
if html:
|
||||
html = html.replace("\n", "<br/>")
|
||||
|
||||
return text, html, relates_to
|
||||
|
||||
|
||||
def telegram_to_matrix(text, entities):
|
||||
try:
|
||||
return _telegram_to_matrix(text, entities)
|
||||
except Exception:
|
||||
log.exception("Failed to convert Telegram format:\n"
|
||||
"message=%s\n"
|
||||
"entities=%s",
|
||||
text, entities)
|
||||
|
||||
|
||||
def _telegram_to_matrix(text, entities):
|
||||
if not entities:
|
||||
return text
|
||||
# See "TEXT LEN EXPLANATION" near start of file
|
||||
text = text.encode(TEMP_ENC)
|
||||
html = []
|
||||
last_offset = 0
|
||||
for entity in entities:
|
||||
entity.offset *= 2
|
||||
entity.length *= 2
|
||||
if entity.offset > last_offset:
|
||||
html.append(escape(text[last_offset:entity.offset].decode(TEMP_ENC)))
|
||||
elif entity.offset < last_offset:
|
||||
continue
|
||||
|
||||
skip_entity = False
|
||||
entity_text = escape(text[entity.offset:entity.offset + entity.length].decode(TEMP_ENC))
|
||||
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:
|
||||
if entity.language:
|
||||
html.append("<pre>"
|
||||
+ f"<code class='language-{entity.language}'>{entity_text}</code>"
|
||||
+ "</pre>")
|
||||
else:
|
||||
html.append(f"<pre><code>{entity_text}</code></pre>")
|
||||
elif entity_type == MessageEntityMention:
|
||||
username = entity_text[1:]
|
||||
|
||||
user = u.User.find_by_username(username)
|
||||
if user:
|
||||
mxid = user.mxid
|
||||
else:
|
||||
puppet = p.Puppet.find_by_username(username)
|
||||
mxid = puppet.mxid if puppet else None
|
||||
if mxid:
|
||||
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
|
||||
else:
|
||||
skip_entity = True
|
||||
elif entity_type == MessageEntityMentionName:
|
||||
user = u.User.get_by_tgid(entity.user_id)
|
||||
if user:
|
||||
mxid = user.mxid
|
||||
else:
|
||||
puppet = p.Puppet.get(entity.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:
|
||||
skip_entity = True
|
||||
elif entity_type == MessageEntityEmail:
|
||||
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
|
||||
elif entity_type in {MessageEntityTextUrl, MessageEntityUrl}:
|
||||
url = escape(entity.url) if entity_type == MessageEntityTextUrl else entity_text
|
||||
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
|
||||
url = "http://" + url
|
||||
html.append(f"<a href='{url}'>{entity_text}</a>")
|
||||
elif entity_type == MessageEntityBotCommand:
|
||||
html.append(f"<font color='blue'>!{entity_text[1:]}")
|
||||
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:].decode(TEMP_ENC))
|
||||
|
||||
return "".join(html)
|
||||
|
||||
# endregion
|
||||
@@ -1,240 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import logging
|
||||
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
|
||||
from .user import User
|
||||
from .portal import Portal
|
||||
from .puppet import Puppet
|
||||
from .commands import CommandHandler
|
||||
|
||||
|
||||
class MatrixHandler:
|
||||
log = logging.getLogger("mau.mx")
|
||||
|
||||
def __init__(self, context):
|
||||
self.az, self.db, self.config, _ = context
|
||||
self.commands = CommandHandler(context)
|
||||
|
||||
self.az.matrix_event_handler(self.handle_event)
|
||||
|
||||
async def init_as_bot(self):
|
||||
self.az.intent.set_display_name(
|
||||
self.config.get("appservice.bot_displayname", "Telegram bridge bot"))
|
||||
|
||||
async def handle_puppet_invite(self, room, puppet, inviter):
|
||||
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room}")
|
||||
if not inviter.logged_in:
|
||||
await puppet.intent.error_and_leave(
|
||||
room, text="Please log in before inviting Telegram puppets.")
|
||||
return
|
||||
portal = Portal.get_by_mxid(room)
|
||||
if portal:
|
||||
if portal.peer_type == "user":
|
||||
await puppet.intent.error_and_leave(
|
||||
room, text="You can not invite additional users to private chats.")
|
||||
return
|
||||
await portal.invite_telegram(inviter, puppet)
|
||||
await puppet.intent.join_room(room)
|
||||
return
|
||||
try:
|
||||
members = await self.az.intent.get_room_members(room)
|
||||
except MatrixRequestError:
|
||||
members = []
|
||||
if self.az.intent.mxid not in members:
|
||||
if len(members) > 1:
|
||||
await puppet.intent.error_and_leave(room, text=None, html=(
|
||||
f"Please invite "
|
||||
+ f"<a href='https://matrix.to/#/{self.az.intent.mxid}'>the bridge bot</a> "
|
||||
+ f"first if you want to create a Telegram chat."))
|
||||
return
|
||||
|
||||
await puppet.intent.join_room(room)
|
||||
portal = Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
|
||||
if portal.mxid:
|
||||
try:
|
||||
await puppet.intent.invite(portal.mxid, inviter.mxid)
|
||||
await puppet.intent.send_notice(room, text=None, html=(
|
||||
"You already have a private chat with me: "
|
||||
+ f"<a href='https://matrix.to/#/{portal.mxid}'>"
|
||||
+ "Link to room"
|
||||
+ "</a>"))
|
||||
await puppet.intent.leave_room(room)
|
||||
return
|
||||
except MatrixRequestError:
|
||||
pass
|
||||
portal.mxid = room
|
||||
portal.save()
|
||||
inviter.register_portal(portal)
|
||||
await puppet.intent.send_notice(room, "Portal to private chat created.")
|
||||
else:
|
||||
await puppet.intent.join_room(room)
|
||||
await puppet.intent.send_notice(room, "This puppet will remain inactive until a "
|
||||
"Telegram chat is created for this room.")
|
||||
|
||||
async def handle_invite(self, room, user, inviter):
|
||||
inviter = User.get_by_mxid(inviter)
|
||||
if not inviter.whitelisted:
|
||||
return
|
||||
elif user == self.az.bot_mxid:
|
||||
await self.az.intent.join_room(room)
|
||||
return
|
||||
|
||||
puppet = Puppet.get_by_mxid(user)
|
||||
if puppet:
|
||||
await self.handle_puppet_invite(room, puppet, inviter)
|
||||
return
|
||||
|
||||
user = User.get_by_mxid(user, create=False)
|
||||
portal = Portal.get_by_mxid(room)
|
||||
if user and user.has_full_access and portal:
|
||||
await portal.invite_telegram(inviter, user)
|
||||
return
|
||||
|
||||
# The rest can probably be ignored
|
||||
self.log.debug(f"{inviter} invited {user} to {room}")
|
||||
|
||||
async def handle_join(self, room, user):
|
||||
user = User.get_by_mxid(user)
|
||||
|
||||
portal = Portal.get_by_mxid(room)
|
||||
if not portal:
|
||||
return
|
||||
|
||||
if not user.whitelisted:
|
||||
await portal.main_intent.kick(room, user.mxid,
|
||||
"You are not whitelisted on this Telegram bridge.")
|
||||
return
|
||||
elif not user.logged_in:
|
||||
# TODO[waiting-for-bots] once we have bot support, this won't be needed.
|
||||
await portal.main_intent.kick(room, user.mxid,
|
||||
"You are not logged into this Telegram bridge.")
|
||||
return
|
||||
|
||||
self.log.debug(f"{user} joined {room}")
|
||||
# TODO join Telegram chat if applicable
|
||||
|
||||
async def handle_part(self, room, user, sender):
|
||||
self.log.debug(f"{user} left {room}")
|
||||
|
||||
sender = User.get_by_mxid(sender, create=False)
|
||||
|
||||
portal = Portal.get_by_mxid(room)
|
||||
if not portal:
|
||||
return
|
||||
|
||||
puppet = Puppet.get_by_mxid(user)
|
||||
if sender and puppet:
|
||||
await portal.leave_matrix(puppet, sender)
|
||||
|
||||
user = User.get_by_mxid(user, create=False)
|
||||
if user and user.logged_in:
|
||||
await portal.leave_matrix(user, sender)
|
||||
|
||||
def is_command(self, message):
|
||||
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):
|
||||
self.log.debug(f"{sender} sent {message} to ${room}")
|
||||
|
||||
is_command, text = self.is_command(message)
|
||||
sender = User.get_by_mxid(sender)
|
||||
|
||||
portal = Portal.get_by_mxid(room)
|
||||
if sender.has_full_access and portal and not is_command:
|
||||
await portal.handle_matrix_message(sender, message, event_id)
|
||||
return
|
||||
|
||||
if message["msgtype"] != "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)
|
||||
|
||||
async def handle_redaction(self, room, sender, event_id):
|
||||
portal = Portal.get_by_mxid(room)
|
||||
sender = User.get_by_mxid(sender)
|
||||
if sender.has_full_access and portal:
|
||||
await portal.handle_matrix_deletion(sender, event_id)
|
||||
|
||||
async def handle_power_levels(self, room, sender, new, old):
|
||||
portal = Portal.get_by_mxid(room)
|
||||
sender = User.get_by_mxid(sender)
|
||||
if sender.has_full_access and portal:
|
||||
await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
|
||||
|
||||
async def handle_room_meta(self, type, room, sender, content):
|
||||
portal = Portal.get_by_mxid(room)
|
||||
sender = User.get_by_mxid(sender)
|
||||
if sender.has_full_access 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"),
|
||||
}[type]
|
||||
if content_key not in content:
|
||||
# FIXME handle
|
||||
pass
|
||||
await handler(sender, content[content_key])
|
||||
|
||||
def filter_matrix_event(self, event):
|
||||
return (event["sender"] == self.az.bot_mxid
|
||||
or Puppet.get_id_from_mxid(event["sender"]) is not None)
|
||||
|
||||
async def handle_event(self, evt):
|
||||
if self.filter_matrix_event(evt):
|
||||
return
|
||||
self.log.debug("Received event: %s", evt)
|
||||
type = evt["type"]
|
||||
content = evt.get("content", {})
|
||||
if type == "m.room.member":
|
||||
membership = content.get("membership", "")
|
||||
if membership == "invite":
|
||||
await self.handle_invite(evt["room_id"], evt["state_key"], evt["sender"])
|
||||
elif membership == "leave":
|
||||
await self.handle_part(evt["room_id"], evt["state_key"], evt["sender"])
|
||||
elif membership == "join":
|
||||
await self.handle_join(evt["room_id"], evt["state_key"])
|
||||
elif type == "m.room.message":
|
||||
await self.handle_message(evt["room_id"], evt["sender"], content, evt["event_id"])
|
||||
elif type == "m.room.redaction":
|
||||
await self.handle_redaction(evt["room_id"], evt["sender"], evt["redacts"])
|
||||
elif type == "m.room.power_levels":
|
||||
await self.handle_power_levels(evt["room_id"], evt["sender"], evt["content"],
|
||||
evt["prev_content"])
|
||||
elif type == "m.room.name" or type == "m.room.avatar" or type == "m.room.topic":
|
||||
await self.handle_room_meta(type, evt["room_id"], evt["sender"], evt["content"])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,182 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from difflib import SequenceMatcher
|
||||
import re
|
||||
import logging
|
||||
|
||||
from telethon.tl.types import UserProfilePhoto, PeerUser
|
||||
from telethon.errors.rpc_error_list import LocationInvalidError
|
||||
|
||||
from .db import Puppet as DBPuppet
|
||||
|
||||
config = None
|
||||
|
||||
|
||||
class Puppet:
|
||||
log = logging.getLogger("mau.puppet")
|
||||
db = None
|
||||
az = None
|
||||
mxid_regex = None
|
||||
cache = {}
|
||||
|
||||
def __init__(self, id=None, username=None, displayname=None, photo_id=None):
|
||||
self.id = id
|
||||
|
||||
self.localpart = config.get("bridge.username_template", "telegram_{userid}").format(
|
||||
userid=self.id)
|
||||
hs = config["homeserver"]["domain"]
|
||||
self.mxid = f"@{self.localpart}:{hs}"
|
||||
self.username = username
|
||||
self.displayname = displayname
|
||||
self.photo_id = photo_id
|
||||
self.intent = self.az.intent.user(self.mxid)
|
||||
|
||||
self.cache[id] = self
|
||||
|
||||
@property
|
||||
def tgid(self):
|
||||
return self.id
|
||||
|
||||
def to_db(self):
|
||||
return self.db.merge(
|
||||
DBPuppet(id=self.id, username=self.username, displayname=self.displayname,
|
||||
photo_id=self.photo_id))
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db_puppet):
|
||||
return Puppet(db_puppet.id, db_puppet.username, db_puppet.displayname, db_puppet.photo_id)
|
||||
|
||||
def save(self):
|
||||
self.to_db()
|
||||
self.db.commit()
|
||||
|
||||
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, 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 not name:
|
||||
name = info.id
|
||||
|
||||
if not 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
|
||||
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
async def update_displayname(self, source, info):
|
||||
displayname = self.get_displayname(info)
|
||||
if displayname != self.displayname:
|
||||
await self.intent.set_display_name(displayname)
|
||||
self.displayname = displayname
|
||||
return True
|
||||
|
||||
async def update_avatar(self, source, photo):
|
||||
photo_id = f"{photo.volume_id}-{photo.local_id}"
|
||||
if self.photo_id != photo_id:
|
||||
try:
|
||||
file = await source.client.download_file_bytes(photo)
|
||||
except LocationInvalidError:
|
||||
return False
|
||||
uploaded = await self.intent.upload_file(file)
|
||||
await self.intent.set_avatar(uploaded["content_uri"])
|
||||
self.photo_id = photo_id
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get(cls, id, create=True):
|
||||
try:
|
||||
return cls.cache[id]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
puppet = DBPuppet.query.get(id)
|
||||
if puppet:
|
||||
return cls.from_db(puppet)
|
||||
|
||||
if create:
|
||||
puppet = cls(id)
|
||||
cls.db.add(puppet.to_db())
|
||||
cls.db.commit()
|
||||
return puppet
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid, create=True):
|
||||
tgid = cls.get_id_from_mxid(mxid)
|
||||
return cls.get(tgid, create) if tgid else None
|
||||
|
||||
@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 find_by_username(cls, username):
|
||||
for _, puppet in cls.cache.items():
|
||||
if puppet.username == username:
|
||||
return puppet
|
||||
|
||||
puppet = DBPuppet.query.filter(DBPuppet.username == username).one_or_none()
|
||||
if puppet:
|
||||
return cls.from_db(puppet)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def init(context):
|
||||
global config
|
||||
Puppet.az, Puppet.db, config, _ = context
|
||||
localpart = config.get("bridge.username_template", "telegram_{userid}").format(userid="(.+)")
|
||||
hs = config["homeserver"]["domain"]
|
||||
Puppet.mxid_regex = re.compile(f"@{localpart}:{hs}")
|
||||
@@ -1,84 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from io import BytesIO
|
||||
|
||||
from telethon import TelegramClient
|
||||
from telethon.tl.functions.messages import SendMessageRequest, SendMediaRequest
|
||||
from telethon.tl.types import *
|
||||
|
||||
|
||||
class MautrixTelegramClient(TelegramClient):
|
||||
async def send_message(self, entity, message, reply_to=None, entities=None, link_preview=True):
|
||||
entity = await self.get_input_entity(entity)
|
||||
|
||||
request = SendMessageRequest(
|
||||
peer=entity,
|
||||
message=message,
|
||||
entities=entities,
|
||||
no_webpage=not link_preview,
|
||||
reply_to_msg_id=self._get_message_id(reply_to)
|
||||
)
|
||||
result = await self(request)
|
||||
if isinstance(result, UpdateShortSentMessage):
|
||||
return Message(
|
||||
id=result.id,
|
||||
to_id=entity,
|
||||
message=message,
|
||||
date=result.date,
|
||||
out=result.out,
|
||||
media=result.media,
|
||||
entities=result.entities
|
||||
)
|
||||
|
||||
return self._get_response_message(request, result)
|
||||
|
||||
async def send_file(self, entity, file, mime_type=None, caption=None, attributes=None,
|
||||
file_name=None, reply_to=None, **kwargs):
|
||||
entity = await self.get_input_entity(entity)
|
||||
reply_to = self._get_message_id(reply_to)
|
||||
|
||||
file_handle = await self.upload_file(file, file_name=file_name, use_cache=False)
|
||||
|
||||
if mime_type == "image/png":
|
||||
media = InputMediaUploadedPhoto(file_handle, caption or "")
|
||||
else:
|
||||
attributes = attributes or []
|
||||
attr_dict = {type(attr): attr for attr in attributes}
|
||||
|
||||
media = InputMediaUploadedDocument(
|
||||
file=file_handle,
|
||||
mime_type=mime_type or "application/octet-stream",
|
||||
attributes=list(attr_dict.values()),
|
||||
caption=caption or "")
|
||||
|
||||
request = SendMediaRequest(entity, media, reply_to_msg_id=reply_to)
|
||||
return self._get_response_message(request, await self(request))
|
||||
|
||||
async def download_file_bytes(self, location):
|
||||
if isinstance(location, Document):
|
||||
location = InputDocumentFileLocation(location.id, location.access_hash,
|
||||
location.version)
|
||||
elif not isinstance(location, (InputFileLocation, InputDocumentFileLocation)):
|
||||
location = InputFileLocation(location.volume_id, location.local_id, location.secret)
|
||||
|
||||
file = BytesIO()
|
||||
|
||||
await self.download_file(location, file)
|
||||
|
||||
data = file.getvalue()
|
||||
file.close()
|
||||
return data
|
||||
@@ -1,474 +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 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 General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import logging
|
||||
import asyncio
|
||||
import platform
|
||||
|
||||
from telethon.tl.types import *
|
||||
from telethon.tl.types.contacts import ContactsNotModified
|
||||
from telethon.tl.types import User as TLUser
|
||||
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
|
||||
from .db import User as DBUser, Message as DBMessage, Contact as DBContact
|
||||
from .tgclient import MautrixTelegramClient
|
||||
from . import portal as po, puppet as pu, __version__
|
||||
|
||||
config = None
|
||||
|
||||
|
||||
class User:
|
||||
loop = None
|
||||
log = logging.getLogger("mau.user")
|
||||
db = None
|
||||
az = None
|
||||
by_mxid = {}
|
||||
by_tgid = {}
|
||||
|
||||
def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0,
|
||||
db_portals=None):
|
||||
self.mxid = mxid
|
||||
self.tgid = tgid
|
||||
self.username = username
|
||||
self.contacts = []
|
||||
self.saved_contacts = saved_contacts
|
||||
self.db_contacts = db_contacts
|
||||
self.portals = {}
|
||||
self.db_portals = db_portals
|
||||
|
||||
self.command_status = None
|
||||
self.connected = False
|
||||
self.client = None
|
||||
self._init_client()
|
||||
|
||||
self.is_admin = self.mxid in config.get("bridge.admins", [])
|
||||
|
||||
whitelist = config.get("bridge.whitelist", None) or [self.mxid]
|
||||
self.whitelisted = not whitelist or self.mxid in whitelist
|
||||
if not self.whitelisted:
|
||||
homeserver = self.mxid[self.mxid.index(":") + 1:]
|
||||
self.whitelisted = homeserver in whitelist
|
||||
|
||||
self.by_mxid[mxid] = self
|
||||
if tgid:
|
||||
self.by_tgid[tgid] = self
|
||||
|
||||
@property
|
||||
def logged_in(self):
|
||||
return self.client.is_user_authorized()
|
||||
|
||||
@property
|
||||
def has_full_access(self):
|
||||
return self.logged_in and self.whitelisted
|
||||
|
||||
@property
|
||||
def db_contacts(self):
|
||||
return [self.db.merge(DBContact(user=self.tgid, contact=puppet.id))
|
||||
for puppet in self.contacts]
|
||||
|
||||
@db_contacts.setter
|
||||
def db_contacts(self, contacts):
|
||||
if contacts:
|
||||
self.contacts = [pu.Puppet.get(entry.contact) for entry in contacts]
|
||||
else:
|
||||
self.contacts = []
|
||||
|
||||
@property
|
||||
def db_portals(self):
|
||||
return [portal.to_db(merge=False) for _, portal in self.portals.items()]
|
||||
|
||||
@db_portals.setter
|
||||
def db_portals(self, portals):
|
||||
if portals:
|
||||
self.portals = {(portal.tgid, portal.tg_receiver):
|
||||
po.Portal.get_by_tgid(portal.tgid, portal.tg_receiver)
|
||||
for portal in portals}
|
||||
else:
|
||||
self.portals = {}
|
||||
|
||||
# region Database conversion
|
||||
|
||||
def to_db(self):
|
||||
return self.db.merge(
|
||||
DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
|
||||
contacts=self.db_contacts, saved_contacts=self.saved_contacts,
|
||||
portals=self.db_portals))
|
||||
|
||||
def save(self):
|
||||
self.to_db()
|
||||
self.db.commit()
|
||||
|
||||
def delete(self):
|
||||
try:
|
||||
del self.by_mxid[self.mxid]
|
||||
del self.by_tgid[self.tgid]
|
||||
except KeyError:
|
||||
pass
|
||||
self.db.delete(self.to_db())
|
||||
self.db.commit()
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db_user):
|
||||
return User(db_user.mxid, db_user.tgid, db_user.tg_username, db_user.contacts,
|
||||
db_user.saved_contacts, db_user.portals)
|
||||
|
||||
# endregion
|
||||
# region Telegram connection management
|
||||
|
||||
def _init_client(self):
|
||||
device = f"{platform.system()} {platform.release()}"
|
||||
sysversion = MautrixTelegramClient.__version__
|
||||
self.client = MautrixTelegramClient(self.mxid,
|
||||
config["telegram.api_id"],
|
||||
config["telegram.api_hash"],
|
||||
loop=self.loop,
|
||||
app_version=__version__,
|
||||
system_version=sysversion,
|
||||
device_model=device)
|
||||
self.client.add_update_handler(self.update_catch)
|
||||
|
||||
async def start(self, delete_unless_authenticated=False):
|
||||
self.connected = await self.client.connect()
|
||||
if self.logged_in:
|
||||
asyncio.ensure_future(self.post_login(), loop=self.loop)
|
||||
elif delete_unless_authenticated:
|
||||
# User not logged in -> forget user
|
||||
self.client.disconnect()
|
||||
self.client.session.delete()
|
||||
self.delete()
|
||||
return self
|
||||
|
||||
async def post_login(self, info=None):
|
||||
try:
|
||||
await self.update_info(info)
|
||||
await self.sync_dialogs()
|
||||
await self.sync_contacts()
|
||||
except Exception:
|
||||
self.log.exception("Failed to run post-login functions")
|
||||
|
||||
def stop(self):
|
||||
self.client.disconnect()
|
||||
self.client = None
|
||||
self.connected = False
|
||||
|
||||
# endregion
|
||||
# region Telegram actions that need custom methods
|
||||
|
||||
async def update_info(self, info=None):
|
||||
info = info or await self.client.get_me()
|
||||
changed = False
|
||||
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):
|
||||
for _, portal in self.portals.items():
|
||||
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, max_results=5, min_similarity=45):
|
||||
results = []
|
||||
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, max_results=5):
|
||||
if len(query) < 5:
|
||||
return []
|
||||
server_results = await self.client(SearchRequest(q=query, limit=max_results))
|
||||
results = []
|
||||
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, force_remote=False):
|
||||
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):
|
||||
dialogs = await self.client.get_dialogs(limit=30)
|
||||
creators = []
|
||||
for dialog in dialogs:
|
||||
entity = dialog.entity
|
||||
invalid = (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden))
|
||||
or (isinstance(entity, Chat) and (entity.deactivated or entity.left)))
|
||||
if invalid:
|
||||
continue
|
||||
portal = po.Portal.get_by_entity(entity)
|
||||
self.portals[portal.tgid_full] = portal
|
||||
creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid]))
|
||||
self.save()
|
||||
await asyncio.gather(*creators, loop=self.loop)
|
||||
|
||||
def register_portal(self, 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):
|
||||
try:
|
||||
del self.portals[portal.tgid_full]
|
||||
self.save()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _hash_contacts(self):
|
||||
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 Telegram update handling
|
||||
|
||||
async def update_catch(self, update):
|
||||
try:
|
||||
await self.update(update)
|
||||
except Exception:
|
||||
self.log.exception("Failed to handle Telegram update")
|
||||
|
||||
async def update(self, update):
|
||||
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
|
||||
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
|
||||
await self.update_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):
|
||||
portal = po.Portal.get_by_tgid(update.participants.chat_id)
|
||||
if portal and portal.mxid:
|
||||
await portal.update_telegram_participants(update.participants.participants)
|
||||
elif isinstance(update, UpdateChannelPinnedMessage):
|
||||
portal = po.Portal.get_by_tgid(update.channel_id)
|
||||
if portal and portal.mxid:
|
||||
await portal.update_telegram_pin(self, update.id)
|
||||
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)
|
||||
|
||||
async def update_read_receipt(self, update):
|
||||
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):
|
||||
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):
|
||||
puppet = pu.Puppet.get(update.user_id)
|
||||
user = User.get_by_tgid(update.user_id)
|
||||
await portal.set_telegram_admin(puppet, user)
|
||||
|
||||
async def update_typing(self, update):
|
||||
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):
|
||||
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()
|
||||
|
||||
async def update_status(self, update):
|
||||
puppet = pu.Puppet.get(update.user_id)
|
||||
if isinstance(update.status, UserStatusOnline):
|
||||
await puppet.intent.set_presence("online")
|
||||
elif isinstance(update.status, UserStatusOffline):
|
||||
await puppet.intent.set_presence("offline")
|
||||
else:
|
||||
self.log.warning("Unexpected user status update: %s", update)
|
||||
return
|
||||
|
||||
def get_message_details(self, update):
|
||||
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
|
||||
|
||||
def update_message(self, original_update):
|
||||
update, sender, portal = self.get_message_details(original_update)
|
||||
|
||||
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 portal.handle_telegram_action(self, sender, update.action)
|
||||
|
||||
user = sender.tgid if sender else "admin"
|
||||
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
|
||||
self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user)
|
||||
return portal.handle_telegram_edit(self, sender, update)
|
||||
|
||||
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
|
||||
return portal.handle_telegram_message(self, sender, update)
|
||||
|
||||
# endregion
|
||||
# region Class instance lookup
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid, create=True):
|
||||
try:
|
||||
return cls.by_mxid[mxid]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
user = DBUser.query.get(mxid)
|
||||
if user:
|
||||
user = cls.from_db(user)
|
||||
asyncio.ensure_future(user.start(), loop=cls.loop)
|
||||
return user
|
||||
|
||||
if create:
|
||||
user = cls(mxid)
|
||||
cls.db.add(user.to_db())
|
||||
cls.db.commit()
|
||||
asyncio.ensure_future(user.start(), loop=cls.loop)
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_tgid(cls, tgid):
|
||||
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)
|
||||
asyncio.ensure_future(user.start(), loop=cls.loop)
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def find_by_username(cls, username):
|
||||
for _, user in cls.by_tgid.items():
|
||||
if user.username == username:
|
||||
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):
|
||||
global config
|
||||
User.az, User.db, config, User.loop = context
|
||||
|
||||
users = [User.from_db(user) for user in DBUser.query.all()]
|
||||
return [user.start(delete_unless_authenticated=True) for user in users]
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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 emojis
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"go.mau.fi/util/exstrings"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
)
|
||||
|
||||
//go:embed unicodemojipack.json
|
||||
var unicodemojiPackJSON string
|
||||
var initOnce sync.Once
|
||||
|
||||
var unicodemojiPack = map[string]int64{}
|
||||
var reverseUnicodemojiPack = map[int64]string{}
|
||||
|
||||
func doInit() {
|
||||
if err := json.Unmarshal(exstrings.UnsafeBytes(unicodemojiPackJSON), &unicodemojiPack); err != nil {
|
||||
panic(fmt.Errorf("Failed to unmarshal unicodemojipack: %w", err))
|
||||
}
|
||||
|
||||
for k, v := range unicodemojiPack {
|
||||
reverseUnicodemojiPack[v] = k
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertKnownEmojis converts known document IDs from the unicode emoji pack
|
||||
// to the corresponding unicode string and returns the remaining IDs.
|
||||
func ConvertKnownEmojis(emojiIDs []int64) (result map[networkid.EmojiID]EmojiInfo, remaining []int64) {
|
||||
initOnce.Do(doInit)
|
||||
result = map[networkid.EmojiID]EmojiInfo{}
|
||||
for _, e := range emojiIDs {
|
||||
if v, ok := reverseUnicodemojiPack[e]; ok {
|
||||
result[ids.MakeEmojiIDFromDocumentID(e)] = EmojiInfo{Emoji: v}
|
||||
} else {
|
||||
remaining = append(remaining, e)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GetEmojiDocumentID(emoji string) (int64, bool) {
|
||||
initOnce.Do(doInit)
|
||||
id, ok := unicodemojiPack[emoji]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// EmojiInfo contains information about an emoji.
|
||||
type EmojiInfo struct {
|
||||
Emoji string
|
||||
DocumentID int64
|
||||
EmojiURI id.ContentURIString
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,126 @@
|
||||
# Get your own API keys at https://my.telegram.org/apps
|
||||
api_id: 12345
|
||||
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
|
||||
|
||||
# Device info shown in the Telegram device list.
|
||||
device_info:
|
||||
device_model: mautrix-telegram
|
||||
system_version:
|
||||
app_version: auto
|
||||
lang_code: en
|
||||
system_lang_code: en
|
||||
|
||||
# Settings for converting animated stickers.
|
||||
animated_sticker:
|
||||
# Format to which animated stickers should be converted.
|
||||
#
|
||||
# disable - no conversion, send as-is (gzipped lottie)
|
||||
# png - converts to non-animated png (fastest),
|
||||
# gif - converts to animated gif
|
||||
# webm - converts to webm video, requires ffmpeg executable with vp9 codec
|
||||
# and webm container support
|
||||
# webp - converts to animated webp, requires ffmpeg executable with webp
|
||||
# codec/container support
|
||||
target: gif
|
||||
# Should video stickers be converted to the specified format as well?
|
||||
convert_from_webm: false
|
||||
# Arguments for converter. All converters take width and height.
|
||||
args:
|
||||
width: 256
|
||||
height: 256
|
||||
fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended)
|
||||
|
||||
# Settings for syncing the member list for portals.
|
||||
member_list:
|
||||
# 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.
|
||||
#
|
||||
# -1 means no limit (which means it's limited to 10000 by the server)
|
||||
max_initial_sync: 100
|
||||
# Whether or not to sync the member list in broadcast channels. If
|
||||
# disabled, members will still be synced when they send messages.
|
||||
#
|
||||
# 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_broadcast_channels: false
|
||||
# Whether or not to skip deleted members when syncing members.
|
||||
skip_deleted: true
|
||||
|
||||
# Settings for pings to the Telegram server.
|
||||
ping:
|
||||
# The interval (in seconds) between pings.
|
||||
interval_seconds: 30
|
||||
# The timeout (in seconds) for a single ping.
|
||||
timeout_seconds: 10
|
||||
|
||||
# Proxy settings
|
||||
proxy:
|
||||
# Allowed types: disabled, socks5, mtproxy
|
||||
type: disabled
|
||||
# Proxy IP address/domain name and port.
|
||||
address: "127.0.0.1:1080"
|
||||
# Proxy authentication (optional). Put MTProxy secret in password field.
|
||||
# For mtproxy, the secret must be hex-encoded (the same form mtg/MTProxy
|
||||
# tools print, e.g. "ee" + 16-byte secret + cloak domain hex for faketls).
|
||||
username:
|
||||
password:
|
||||
|
||||
sync:
|
||||
# Number of most recently active dialogs to check when syncing chats.
|
||||
# Set to -1 to remove limit.
|
||||
update_limit: 100
|
||||
# Number of most recently active dialogs to create portals for when syncing chats.
|
||||
# Set to -1 to remove limit.
|
||||
create_limit: 15
|
||||
# Number of chats to sync immediately on login before the data export is accepted.
|
||||
# The create_limit above still applies. This is ignored if takeout.dialog_sync is false.
|
||||
login_sync_limit: 15
|
||||
# Whether or not to sync and create portals for direct chats at startup.
|
||||
direct_chats: true
|
||||
|
||||
takeout:
|
||||
# Should the bridge use the data export mode for syncing the full chat list?
|
||||
# If true, login_sync_limit of chats is synced immediately on login,
|
||||
# then the rest are synced after the takeout is accepted.
|
||||
dialog_sync: false
|
||||
# Should the bridge use the data export mode for forward backfilling messages?
|
||||
# This should be set to true if the forward backfill limits are set to high values,
|
||||
# but is probably not necessary otherwise.
|
||||
forward_backfill: false
|
||||
# Should the bridge use the data export mode for backward backfilling messages?
|
||||
# This only affects the backfill queue, which is only available on Beeper.
|
||||
backward_backfill: false
|
||||
|
||||
# Maximum number of participants in chats to bridge. Only applies when the
|
||||
# portal is being created. If there are more members when trying to create a
|
||||
# room, the room creation will be cancelled.
|
||||
#
|
||||
# -1 means no limit (which means all chats can be bridged)
|
||||
max_member_count: -1
|
||||
# Should personal avatars (that are only visible to specific users) be allowed?
|
||||
contact_avatars: false
|
||||
# Should contact names be updated from any source even if a name is already set?
|
||||
# Note that contact names will still be used if there's no other name available.
|
||||
contact_names: false
|
||||
# Should the bridge send all unicode reactions as custom emoji reactions to
|
||||
# Telegram? By default, the bridge only uses custom emojis for unicode emojis
|
||||
# that aren't allowed in reactions.
|
||||
always_custom_emoji_reaction: false
|
||||
# The avatar to use for the Telegram Saved Messages chat
|
||||
saved_message_avatar: mxc://maunium.net/XhhfHoPejeneOngMyBbtyWDk
|
||||
# Create a new room and tombstone the old one when upgrading rooms
|
||||
always_tombstone_on_supergroup_migration: false
|
||||
# Maximum number of pixels in an image before sending to Telegram as a
|
||||
# document. Defaults to 4096x4096 = 16777216.
|
||||
image_as_file_pixels: 16777216
|
||||
# Should view-once messages be disabled entirely?
|
||||
disable_view_once: false
|
||||
# Displayname template for Telegram users.
|
||||
# {{ .FullName }} - the full name of the Telegram user
|
||||
# {{ .FirstName }} - the first name of the Telegram user
|
||||
# {{ .LastName }} - the last name of the Telegram user
|
||||
# {{ .Username }} - the primary username of the Telegram user, if the user has one
|
||||
# {{ .UserID }} - the internal user ID of the Telegram user
|
||||
# {{ .Deleted }} - true if the user has been deleted, false otherwise
|
||||
displayname_template: "{{ if .Deleted }}Deleted account {{ .UserID }}{{ else }}{{ .FullName }}{{ end }}"
|
||||
@@ -0,0 +1,51 @@
|
||||
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||
// Copyright (C) 2025 Automattic Inc.
|
||||
//
|
||||
// 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"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Future[T any] struct {
|
||||
value T
|
||||
err error
|
||||
ready chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func NewFuture[T any]() *Future[T] {
|
||||
return &Future[T]{
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Future[T]) Set(value T) {
|
||||
f.once.Do(func() {
|
||||
f.value = value
|
||||
close(f.ready)
|
||||
})
|
||||
}
|
||||
|
||||
func (f *Future[T]) Get(ctx context.Context) (T, error) {
|
||||
select {
|
||||
case <-f.ready:
|
||||
return f.value, nil
|
||||
case <-ctx.Done():
|
||||
return f.value, ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO this should probably be moved to mautrix-go
|
||||
|
||||
type GeoURI struct {
|
||||
Lat float64
|
||||
Long float64
|
||||
}
|
||||
|
||||
func GeoURIFromLatLong(lat, long float64) GeoURI {
|
||||
return GeoURI{lat, long}
|
||||
}
|
||||
|
||||
func ParseGeoURI(uri string) (g GeoURI, err error) {
|
||||
if !strings.HasPrefix(uri, "geo:") {
|
||||
return g, fmt.Errorf("invalid geo URI: %s", uri)
|
||||
}
|
||||
coordinates := strings.Split(strings.TrimPrefix(uri, "geo:"), ";")[0]
|
||||
parts := strings.Split(coordinates, ",")
|
||||
if len(parts) != 2 {
|
||||
return g, fmt.Errorf("geo coordinates not formatted properly")
|
||||
}
|
||||
g.Lat, err = strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return g, fmt.Errorf("failed to parse latitude: %w", err)
|
||||
}
|
||||
g.Long, err = strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil {
|
||||
return g, fmt.Errorf("failed to parse longitude: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (g GeoURI) URI() string {
|
||||
return fmt.Sprintf("geo:%f,%f", g.Lat, g.Long)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1067
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,529 @@
|
||||
2FA_CONFIRM_WAIT_X,420,The account is 2FA protected so it will be deleted in a week. Otherwise it can be reset in {seconds}
|
||||
ABOUT_TOO_LONG,400,The provided bio is too long
|
||||
ACCESS_TOKEN_EXPIRED,400,Bot token expired
|
||||
ACCESS_TOKEN_INVALID,400,The provided token is not valid
|
||||
ACTIVE_USER_REQUIRED,401,The method is only available to already activated users
|
||||
ADMINS_TOO_MUCH,400,Too many admins
|
||||
ADMIN_ID_INVALID,400,The specified admin ID is invalid
|
||||
ADMIN_RANK_EMOJI_NOT_ALLOWED,400,Emoji are not allowed in admin titles or ranks
|
||||
ADMIN_RANK_INVALID,400,The given admin title or rank was invalid (possibly larger than 16 characters)
|
||||
ALBUM_PHOTOS_TOO_MANY,400,Too many photos were included in the album
|
||||
API_ID_INVALID,400,The api_id/api_hash combination is invalid
|
||||
API_ID_PUBLISHED_FLOOD,400,"This API id was published somewhere, you can't use it now"
|
||||
ARTICLE_TITLE_EMPTY,400,The title of the article is empty
|
||||
AUDIO_CONTENT_URL_EMPTY,400,The remote URL specified in the content field is empty
|
||||
AUDIO_TITLE_EMPTY,400,The title attribute of the audio must be non-empty
|
||||
AUTH_BYTES_INVALID,400,The provided authorization is invalid
|
||||
AUTH_KEY_DUPLICATED,406,"The authorization key (session file) was used under two different IP addresses simultaneously, and can no longer be used. Use the same session exclusively, or use different sessions"
|
||||
AUTH_KEY_INVALID,401,The key is invalid
|
||||
AUTH_KEY_PERM_EMPTY,401,"The method is unavailable for temporary authorization key, not bound to permanent"
|
||||
AUTH_KEY_UNREGISTERED,401,The key is not registered in the system
|
||||
AUTH_RESTART,500,Restart the authorization process
|
||||
AUTH_TOKEN_ALREADY_ACCEPTED,400,The authorization token was already used
|
||||
AUTH_TOKEN_EXCEPTION,400,An error occurred while importing the auth token
|
||||
AUTH_TOKEN_EXPIRED,400,The provided authorization token has expired and the updated QR-code must be re-scanned
|
||||
AUTH_TOKEN_INVALID,400,An invalid authorization token was provided
|
||||
AUTH_TOKEN_INVALID2,400,An invalid authorization token was provided
|
||||
AUTH_TOKEN_INVALIDX,400,The specified auth token is invalid
|
||||
AUTOARCHIVE_NOT_AVAILABLE,400,You cannot use this feature yet
|
||||
BANK_CARD_NUMBER_INVALID,400,Incorrect credit card number
|
||||
BANNED_RIGHTS_INVALID,400,"You cannot use that set of permissions in this request, i.e. restricting view_messages as a default"
|
||||
BASE_PORT_LOC_INVALID,400,Base port location invalid
|
||||
BOTS_TOO_MUCH,400,There are too many bots in this chat/channel
|
||||
BOT_CHANNELS_NA,400,Bots can't edit admin privileges
|
||||
BOT_COMMAND_DESCRIPTION_INVALID,400,"The command description was empty, too long or had invalid characters used"
|
||||
BOT_COMMAND_INVALID,400,The specified command is invalid
|
||||
BOT_COMMANDS_TOO_MUCH,400,"The provided commands are too many"
|
||||
BOT_DOMAIN_INVALID,400,The domain used for the auth button does not match the one configured in @BotFather
|
||||
BOT_GAMES_DISABLED,400,Bot games cannot be used in this type of chat
|
||||
BOT_GROUPS_BLOCKED,400,This bot can't be added to groups
|
||||
BOT_INLINE_DISABLED,400,This bot can't be used in inline mode
|
||||
BOT_INVALID,400,This is not a valid bot
|
||||
BOT_METHOD_INVALID,400,The API access for bot users is restricted. The method you tried to invoke cannot be executed as a bot
|
||||
BOT_MISSING,400,This method can only be run by a bot
|
||||
BOT_ONESIDE_NOT_AVAIL,400,Bots can't pin messages in PM just for themselves
|
||||
BOT_PAYMENTS_DISABLED,400,This method can only be run by a bot
|
||||
BOT_POLLS_DISABLED,400,You cannot create polls under a bot account
|
||||
BOT_RESPONSE_TIMEOUT,400,The bot did not answer to the callback query in time
|
||||
BOT_SCORE_NOT_MODIFIED,400,The score wasn't modified
|
||||
BROADCAST_CALLS_DISABLED,400,
|
||||
BROADCAST_FORBIDDEN,403,The request cannot be used in broadcast channels
|
||||
BROADCAST_ID_INVALID,400,The channel is invalid
|
||||
BROADCAST_PUBLIC_VOTERS_FORBIDDEN,400,You cannot broadcast polls where the voters are public
|
||||
BROADCAST_REQUIRED,400,The request can only be used with a broadcast channel
|
||||
BUTTON_DATA_INVALID,400,The provided button data is invalid
|
||||
BUTTON_TEXT_INVALID,400,The specified button text is invalid
|
||||
BUTTON_TYPE_INVALID,400,The type of one of the buttons you provided is invalid
|
||||
BUTTON_URL_INVALID,400,Button URL invalid
|
||||
BUTTON_USER_PRIVACY_RESTRICTED,400,The privacy setting of the user specified in a [inputKeyboardButtonUserProfile](/constructor/inputKeyboardButtonUserProfile) button do not allow creating such a button
|
||||
CALL_ALREADY_ACCEPTED,400,The call was already accepted
|
||||
CALL_ALREADY_DECLINED,400,The call was already declined
|
||||
CALL_OCCUPY_FAILED,500,The call failed because the user is already making another call
|
||||
CALL_PEER_INVALID,400,The provided call peer object is invalid
|
||||
CALL_PROTOCOL_FLAGS_INVALID,400,Call protocol flags invalid
|
||||
CDN_METHOD_INVALID,400,This method cannot be invoked on a CDN server. Refer to https://core.telegram.org/cdn#schema for available methods
|
||||
CDN_UPLOAD_TIMEOUT,500,A server-side timeout occurred while reuploading the file to the CDN DC
|
||||
CHANNELS_ADMIN_LOCATED_TOO_MUCH,400,The user has reached the limit of public geogroups
|
||||
CHANNELS_ADMIN_PUBLIC_TOO_MUCH,400,"You're admin of too many public channels, make some channels private to change the username of this channel"
|
||||
CHANNELS_TOO_MUCH,400,You have joined too many channels/supergroups
|
||||
CHANNEL_BANNED,400,The channel is banned
|
||||
CHANNEL_FORUM_MISSING,400,
|
||||
CHANNEL_ID_INVALID,400,The specified supergroup ID is invalid
|
||||
CHANNEL_INVALID,400,"Invalid channel object. Make sure to pass the right types, for instance making sure that the request is designed for channels or otherwise look for a different one more suited"
|
||||
CHANNEL_PARICIPANT_MISSING,400,The current user is not in the channel
|
||||
CHANNEL_PRIVATE,400 406,The channel specified is private and you lack permission to access it. Another reason may be that you were banned from it
|
||||
CHANNEL_PUBLIC_GROUP_NA,403,channel/supergroup not available
|
||||
CHANNEL_TOO_BIG,400,
|
||||
CHANNEL_TOO_LARGE,400 406,Channel is too large to be deleted; this error is issued when trying to delete channels with more than 1000 members (subject to change)
|
||||
CHAT_ABOUT_NOT_MODIFIED,400,About text has not changed
|
||||
CHAT_ABOUT_TOO_LONG,400,Chat about too long
|
||||
CHAT_ADMIN_INVITE_REQUIRED,403,You do not have the rights to do this
|
||||
CHAT_ADMIN_REQUIRED,400 403,"Chat admin privileges are required to do that in the specified chat (for example, to send a message in a channel which is not yours), or invalid permissions used for the channel or group"
|
||||
CHAT_DISCUSSION_UNALLOWED,400,
|
||||
CHAT_FORBIDDEN,403,You cannot write in this chat
|
||||
CHAT_FORWARDS_RESTRICTED,400 406,You can't forward messages from a protected chat
|
||||
CHAT_GET_FAILED,500,
|
||||
CHAT_GUEST_SEND_FORBIDDEN,403,"You join the discussion group before commenting, see [here](/api/discussion#requiring-users-to-join-the-group) for more info"
|
||||
CHAT_ID_EMPTY,400,The provided chat ID is empty
|
||||
CHAT_ID_GENERATE_FAILED,500,Failure while generating the chat ID
|
||||
CHAT_ID_INVALID,400,"Invalid object ID for a chat. Make sure to pass the right types, for instance making sure that the request is designed for chats (not channels/megagroups) or otherwise look for a different one more suited\nAn example working with a megagroup and AddChatUserRequest, it will fail because megagroups are channels. Use InviteToChannelRequest instead"
|
||||
CHAT_INVALID,400,The chat is invalid for this request
|
||||
CHAT_INVITE_PERMANENT,400,You can't set an expiration date on permanent invite links
|
||||
CHAT_LINK_EXISTS,400,The chat is linked to a channel and cannot be used in that request
|
||||
CHAT_NOT_MODIFIED,400,"The chat or channel wasn't modified (title, invites, username, admins, etc. are the same)"
|
||||
CHAT_RESTRICTED,400,The chat is restricted and cannot be used in that request
|
||||
CHAT_REVOKE_DATE_UNSUPPORTED,400,`min_date` and `max_date` are not available for using with non-user peers
|
||||
CHAT_SEND_GAME_FORBIDDEN,403,You can't send a game to this chat
|
||||
CHAT_SEND_GIFS_FORBIDDEN,403,You can't send gifs in this chat
|
||||
CHAT_SEND_INLINE_FORBIDDEN,400 403,You cannot send inline results in this chat
|
||||
CHAT_SEND_MEDIA_FORBIDDEN,403,You can't send media in this chat
|
||||
CHAT_SEND_POLL_FORBIDDEN,403,You can't send polls in this chat
|
||||
CHAT_SEND_STICKERS_FORBIDDEN,403,You can't send stickers in this chat
|
||||
CHAT_TITLE_EMPTY,400,No chat title provided
|
||||
CHAT_TOO_BIG,400,"This method is not available for groups with more than `chat_read_mark_size_threshold` members, [see client configuration](https://core.telegram.org/api/config#client-configuration)"
|
||||
CHAT_WRITE_FORBIDDEN,403,You can't write in this chat
|
||||
CHP_CALL_FAIL,500,The statistics cannot be retrieved at this time
|
||||
CODE_EMPTY,400,The provided code is empty
|
||||
CODE_HASH_INVALID,400,Code hash invalid
|
||||
CODE_INVALID,400,Code invalid (i.e. from email)
|
||||
CONNECTION_API_ID_INVALID,400,The provided API id is invalid
|
||||
CONNECTION_APP_VERSION_EMPTY,400,App version is empty
|
||||
CONNECTION_DEVICE_MODEL_EMPTY,400,Device model empty
|
||||
CONNECTION_LANG_PACK_INVALID,400,"The specified language pack is not valid. This is meant to be used by official applications only so far, leave it empty"
|
||||
CONNECTION_LAYER_INVALID,400,The very first request must always be InvokeWithLayerRequest
|
||||
CONNECTION_NOT_INITED,400,Connection not initialized
|
||||
CONNECTION_SYSTEM_EMPTY,400,Connection system empty
|
||||
CONNECTION_SYSTEM_LANG_CODE_EMPTY,400,The system language string was empty during connection
|
||||
CONTACT_ADD_MISSING,400,Contact to add is missing
|
||||
CONTACT_ID_INVALID,400,The provided contact ID is invalid
|
||||
CONTACT_NAME_EMPTY,400,The provided contact name cannot be empty
|
||||
CONTACT_REQ_MISSING,400,Missing contact request
|
||||
CREATE_CALL_FAILED,400,An error occurred while creating the call
|
||||
CURRENCY_TOTAL_AMOUNT_INVALID,400,The total amount of all prices is invalid
|
||||
DATA_INVALID,400,Encrypted data invalid
|
||||
DATA_JSON_INVALID,400,The provided JSON data is invalid
|
||||
DATA_TOO_LONG,400,Data too long
|
||||
DATE_EMPTY,400,Date empty
|
||||
DC_ID_INVALID,400,This occurs when an authorization is tried to be exported for the same data center one is currently connected to
|
||||
DH_G_A_INVALID,400,g_a invalid
|
||||
DOCUMENT_INVALID,400,The document file was invalid and can't be used in inline mode
|
||||
EDIT_BOT_INVITE_FORBIDDEN,403,Normal users can't edit invites that were created by bots
|
||||
EMAIL_HASH_EXPIRED,400,The email hash expired and cannot be used to verify it
|
||||
EMAIL_INVALID,400,The given email is invalid
|
||||
EMAIL_UNCONFIRMED,400,Email unconfirmed
|
||||
EMAIL_UNCONFIRMED_X,400,"Email unconfirmed, the length of the code must be {code_length}"
|
||||
EMAIL_VERIFY_EXPIRED,400,The verification email has expired
|
||||
EMOJI_INVALID,400,The specified theme emoji is valid
|
||||
EMOJI_NOT_MODIFIED,400,The theme wasn't changed
|
||||
EMOTICON_EMPTY,400,The emoticon field cannot be empty
|
||||
EMOTICON_INVALID,400,The specified emoticon cannot be used or was not a emoticon
|
||||
EMOTICON_STICKERPACK_MISSING,400,The emoticon sticker pack you are trying to get is missing
|
||||
ENCRYPTED_MESSAGE_INVALID,400,Encrypted message invalid
|
||||
ENCRYPTION_ALREADY_ACCEPTED,400,Secret chat already accepted
|
||||
ENCRYPTION_ALREADY_DECLINED,400,The secret chat was already declined
|
||||
ENCRYPTION_DECLINED,400,The secret chat was declined
|
||||
ENCRYPTION_ID_INVALID,400,The provided secret chat ID is invalid
|
||||
ENCRYPTION_OCCUPY_FAILED,500,TDLib developer claimed it is not an error while accepting secret chats and 500 is used instead of 420
|
||||
ENTITIES_TOO_LONG,400,It is no longer possible to send such long data inside entity tags (for example inline text URLs)
|
||||
ENTITY_BOUNDS_INVALID,400,Some of provided entities have invalid bounds (length is zero or out of the boundaries of the string)
|
||||
ENTITY_MENTION_USER_INVALID,400,You can't use this entity
|
||||
ERROR_TEXT_EMPTY,400,The provided error message is empty
|
||||
EXPIRE_DATE_INVALID,400,The specified expiration date is invalid
|
||||
EXPIRE_FORBIDDEN,400,
|
||||
EXPORT_CARD_INVALID,400,Provided card is invalid
|
||||
EXTERNAL_URL_INVALID,400,External URL invalid
|
||||
FIELD_NAME_EMPTY,400,The field with the name FIELD_NAME is missing
|
||||
FIELD_NAME_INVALID,400,The field with the name FIELD_NAME is invalid
|
||||
FILEREF_UPGRADE_NEEDED,406,The file reference needs to be refreshed before being used again
|
||||
FILE_CONTENT_TYPE_INVALID,400,File content-type is invalid
|
||||
FILE_EMTPY,400,An empty file was provided
|
||||
FILE_ID_INVALID,400,"The provided file id is invalid. Make sure all parameters are present, have the correct type and are not empty (ID, access hash, file reference, thumb size ...)"
|
||||
FILE_MIGRATE_X,303,The file to be accessed is currently stored in DC {new_dc}
|
||||
FILE_PARTS_INVALID,400,The number of file parts is invalid
|
||||
FILE_PART_0_MISSING,400,File part 0 missing
|
||||
FILE_PART_EMPTY,400,The provided file part is empty
|
||||
FILE_PART_INVALID,400,The file part number is invalid
|
||||
FILE_PART_LENGTH_INVALID,400,The length of a file part is invalid
|
||||
FILE_PART_SIZE_CHANGED,400,The file part size (chunk size) cannot change during upload
|
||||
FILE_PART_SIZE_INVALID,400,The provided file part size is invalid
|
||||
FILE_PART_TOO_BIG,400,The uploaded file part is too big
|
||||
FILE_PART_X_MISSING,400,Part {which} of the file is missing from storage
|
||||
FILE_REFERENCE_EMPTY,400,The file reference must exist to access the media and it cannot be empty
|
||||
FILE_REFERENCE_EXPIRED,400,The file reference has expired and is no longer valid or it belongs to self-destructing media and cannot be resent
|
||||
FILE_REFERENCE_INVALID,400,The file reference is invalid or you can't do that operation on such message
|
||||
FILE_TITLE_EMPTY,400,An empty file title was specified
|
||||
FILTER_ID_INVALID,400,The specified filter ID is invalid
|
||||
FILTER_INCLUDE_EMPTY,400,The include_peers vector of the filter is empty
|
||||
FILTER_NOT_SUPPORTED,400,The specified filter cannot be used in this context
|
||||
FILTER_TITLE_EMPTY,400,The title field of the filter is empty
|
||||
FIRSTNAME_INVALID,400,The first name is invalid
|
||||
FLOOD_TEST_PHONE_WAIT_X,420,A wait of {seconds} seconds is required in the test servers
|
||||
FLOOD_WAIT_X,420,A wait of {seconds} seconds is required
|
||||
FLOOD_PREMIUM_WAIT_X,420,A wait of {seconds} seconds is required in non-premium accounts
|
||||
FOLDER_ID_EMPTY,400,The folder you tried to delete was already empty
|
||||
FOLDER_ID_INVALID,400,The folder you tried to use was not valid
|
||||
FRESH_CHANGE_ADMINS_FORBIDDEN,400 406,Recently logged-in users cannot add or change admins
|
||||
FRESH_CHANGE_PHONE_FORBIDDEN,406,Recently logged-in users cannot use this request
|
||||
FRESH_RESET_AUTHORISATION_FORBIDDEN,406,The current session is too new and cannot be used to reset other authorisations yet
|
||||
FROM_MESSAGE_BOT_DISABLED,400,Bots can't use fromMessage min constructors
|
||||
FROM_PEER_INVALID,400,The given from_user peer cannot be used for the parameter
|
||||
GAME_BOT_INVALID,400,You cannot send that game with the current bot
|
||||
GEO_POINT_INVALID,400,Invalid geoposition provided
|
||||
GIF_CONTENT_TYPE_INVALID,400,GIF content-type invalid
|
||||
GIF_ID_INVALID,400,The provided GIF ID is invalid
|
||||
GRAPH_EXPIRED_RELOAD,400,"This graph has expired, please obtain a new graph token"
|
||||
GRAPH_INVALID_RELOAD,400,"Invalid graph token provided, please reload the stats and provide the updated token"
|
||||
GRAPH_OUTDATED_RELOAD,400,"Data can't be used for the channel statistics, graphs outdated"
|
||||
GROUPCALL_ADD_PARTICIPANTS_FAILED,500,
|
||||
GROUPCALL_ALREADY_DISCARDED,400,The group call was already discarded
|
||||
GROUPCALL_ALREADY_STARTED,403,"The groupcall has already started, you can join directly using [phone.joinGroupCall](https://core.telegram.org/method/phone.joinGroupCall)"
|
||||
GROUPCALL_FORBIDDEN,403,The group call has already ended
|
||||
GROUPCALL_INVALID,400,The specified group call is invalid
|
||||
GROUPCALL_JOIN_MISSING,400,You haven't joined this group call
|
||||
GROUPCALL_NOT_MODIFIED,400,Group call settings weren't modified
|
||||
GROUPCALL_SSRC_DUPLICATE_MUCH,400,The app needs to retry joining the group call with a new SSRC value
|
||||
GROUPED_MEDIA_INVALID,400,Invalid grouped media
|
||||
GROUP_CALL_INVALID,400,Group call invalid
|
||||
HASH_INVALID,400,The provided hash is invalid
|
||||
HIDE_REQUESTER_MISSING,400,The join request was missing or was already handled
|
||||
HISTORY_GET_FAILED,500,Fetching of history failed
|
||||
IMAGE_PROCESS_FAILED,400,Failure while processing image
|
||||
IMPORT_FILE_INVALID,400,The file is too large to be imported
|
||||
IMPORT_FORMAT_UNRECOGNIZED,400,Unknown import format
|
||||
IMPORT_ID_INVALID,400,The specified import ID is invalid
|
||||
INLINE_BOT_REQUIRED,403,The action must be performed through an inline bot callback
|
||||
INLINE_RESULT_EXPIRED,400,The inline query expired
|
||||
INPUT_CONSTRUCTOR_INVALID,400,The provided constructor is invalid
|
||||
INPUT_FETCH_ERROR,400,An error occurred while deserializing TL parameters
|
||||
INPUT_FETCH_FAIL,400,Failed deserializing TL payload
|
||||
INPUT_FILTER_INVALID,400,The search query filter is invalid
|
||||
INPUT_LAYER_INVALID,400,The provided layer is invalid
|
||||
INPUT_METHOD_INVALID,400,The invoked method does not exist anymore or has never existed
|
||||
INPUT_REQUEST_TOO_LONG,400,The input request was too long. This may be a bug in the library as it can occur when serializing more bytes than it should (like appending the vector constructor code at the end of a message)
|
||||
INPUT_TEXT_EMPTY,400,The specified text is empty
|
||||
INPUT_USER_DEACTIVATED,400,The specified user was deleted
|
||||
INTERDC_X_CALL_ERROR,500,An error occurred while communicating with DC {dc}
|
||||
INTERDC_X_CALL_RICH_ERROR,500,A rich error occurred while communicating with DC {dc}
|
||||
INVITE_FORBIDDEN_WITH_JOINAS,400,"If the user has anonymously joined a group call as a channel, they can't invite other users to the group call because that would cause deanonymization, because the invite would be sent using the original user ID, not the anonymized channel ID"
|
||||
INVITE_HASH_EMPTY,400,The invite hash is empty
|
||||
INVITE_HASH_EXPIRED,400 406,The chat the user tried to join has expired and is not valid anymore
|
||||
INVITE_HASH_INVALID,400,The invite hash is invalid
|
||||
INVITE_REQUEST_SENT,400,You have successfully requested to join this chat or channel
|
||||
INVITE_REVOKED_MISSING,400,The specified invite link was already revoked or is invalid
|
||||
INVOICE_PAYLOAD_INVALID,400,The specified invoice payload is invalid
|
||||
JOIN_AS_PEER_INVALID,400,The specified peer cannot be used to join a group call
|
||||
LANG_CODE_INVALID,400,The specified language code is invalid
|
||||
LANG_CODE_NOT_SUPPORTED,400,The specified language code is not supported
|
||||
LANG_PACK_INVALID,400,The provided language pack is invalid
|
||||
LASTNAME_INVALID,400,The last name is invalid
|
||||
LIMIT_INVALID,400,An invalid limit was provided. See https://core.telegram.org/api/files#downloading-files
|
||||
LINK_NOT_MODIFIED,400,The channel is already linked to this group
|
||||
LOCATION_INVALID,400,The location given for a file was invalid. See https://core.telegram.org/api/files#downloading-files
|
||||
MAX_DATE_INVALID,400,The specified maximum date is invalid
|
||||
MAX_ID_INVALID,400,The provided max ID is invalid
|
||||
MAX_QTS_INVALID,400,The provided QTS were invalid
|
||||
MD5_CHECKSUM_INVALID,400,The MD5 check-sums do not match
|
||||
MEDIA_CAPTION_TOO_LONG,400,The caption is too long
|
||||
MEDIA_EMPTY,400,The provided media object is invalid or the current account may not be able to send it (such as games as users)
|
||||
MEDIA_GROUPED_INVALID,400,You tried to send media of different types in an album
|
||||
MEDIA_INVALID,400,Media invalid
|
||||
MEDIA_NEW_INVALID,400,The new media to edit the message with is invalid (such as stickers or voice notes)
|
||||
MEDIA_PREV_INVALID,400,The old media cannot be edited with anything else (such as stickers or voice notes)
|
||||
MEDIA_TTL_INVALID,400,
|
||||
MEGAGROUP_ID_INVALID,400,The group is invalid
|
||||
MEGAGROUP_PREHISTORY_HIDDEN,400,You can't set this discussion group because it's history is hidden
|
||||
MEGAGROUP_REQUIRED,400,The request can only be used with a megagroup channel
|
||||
MEMBER_NO_LOCATION,500,An internal failure occurred while fetching user info (couldn't find location)
|
||||
MEMBER_OCCUPY_PRIMARY_LOC_FAILED,500,Occupation of primary member location failed
|
||||
MESSAGE_AUTHOR_REQUIRED,403,Message author required
|
||||
MESSAGE_DELETE_FORBIDDEN,403,"You can't delete one of the messages you tried to delete, most likely because it is a service message."
|
||||
MESSAGE_EDIT_TIME_EXPIRED,400,"You can't edit this message anymore, too much time has passed since its creation."
|
||||
MESSAGE_EMPTY,400,Empty or invalid UTF-8 message was sent
|
||||
MESSAGE_IDS_EMPTY,400,No message ids were provided
|
||||
MESSAGE_ID_INVALID,400,The specified message ID is invalid or you can't do that operation on such message
|
||||
MESSAGE_NOT_MODIFIED,400,Content of the message was not modified
|
||||
MESSAGE_POLL_CLOSED,400,The poll was closed and can no longer be voted on
|
||||
MESSAGE_TOO_LONG,400,Message was too long
|
||||
METHOD_INVALID,400,The API method is invalid and cannot be used
|
||||
MIN_DATE_INVALID,400,The specified minimum date is invalid
|
||||
MSGID_DECREASE_RETRY,500,The request should be retried with a lower message ID
|
||||
MSG_ID_INVALID,400,The message ID used in the peer was invalid
|
||||
MSG_TOO_OLD,400,"[`chat_read_mark_expire_period` seconds](https://core.telegram.org/api/config#chat-read-mark-expire-period) have passed since the message was sent, read receipts were deleted"
|
||||
MSG_WAIT_FAILED,400,A waiting call returned an error
|
||||
MT_SEND_QUEUE_TOO_LONG,500,
|
||||
MULTI_MEDIA_TOO_LONG,400,Too many media files were included in the same album
|
||||
NEED_CHAT_INVALID,500,The provided chat is invalid
|
||||
NEED_MEMBER_INVALID,500,The provided member is invalid or does not exist (for example a thumb size)
|
||||
NETWORK_MIGRATE_X,303,The source IP address is associated with DC {new_dc}
|
||||
NEW_SALT_INVALID,400,The new salt is invalid
|
||||
NEW_SETTINGS_EMPTY,400,"No password is set on the current account, and no new password was specified in `new_settings`"
|
||||
NEW_SETTINGS_INVALID,400,The new settings are invalid
|
||||
NEXT_OFFSET_INVALID,400,The value for next_offset is invalid. Check that it has normal characters and is not too long
|
||||
NOT_ALLOWED,403,
|
||||
OFFSET_INVALID,400,"The given offset was invalid, it must be divisible by 1KB. See https://core.telegram.org/api/files#downloading-files"
|
||||
OFFSET_PEER_ID_INVALID,400,The provided offset peer is invalid
|
||||
OPTIONS_TOO_MUCH,400,You defined too many options for the poll
|
||||
OPTION_INVALID,400,The option specified is invalid and does not exist in the target poll
|
||||
PACK_SHORT_NAME_INVALID,400,"Invalid sticker pack name. It must begin with a letter, can't contain consecutive underscores and must end in ""_by_<bot username>""."
|
||||
PACK_SHORT_NAME_OCCUPIED,400,A stickerpack with this name already exists
|
||||
PACK_TITLE_INVALID,400,The stickerpack title is invalid
|
||||
PARTICIPANTS_TOO_FEW,400,Not enough participants
|
||||
PARTICIPANT_CALL_FAILED,500,Failure while making call
|
||||
PARTICIPANT_ID_INVALID,400,The specified participant ID is invalid
|
||||
PARTICIPANT_JOIN_MISSING,400 403,"Trying to enable a presentation, when the user hasn't joined the Video Chat with [phone.joinGroupCall](https://core.telegram.org/method/phone.joinGroupCall)"
|
||||
PARTICIPANT_VERSION_OUTDATED,400,The other participant does not use an up to date telegram client with support for calls
|
||||
PASSWORD_EMPTY,400,The provided password is empty
|
||||
PASSWORD_HASH_INVALID,400,The password (and thus its hash value) you entered is invalid
|
||||
PASSWORD_MISSING,400,The account must have 2-factor authentication enabled (a password) before this method can be used
|
||||
PASSWORD_RECOVERY_EXPIRED,400,The recovery code has expired
|
||||
PASSWORD_RECOVERY_NA,400,"No email was set, can't recover password via email"
|
||||
PASSWORD_REQUIRED,400,The account must have 2-factor authentication enabled (a password) before this method can be used
|
||||
PASSWORD_TOO_FRESH_X,400,The password was added too recently and {seconds} seconds must pass before using the method
|
||||
PAYMENT_PROVIDER_INVALID,400,The payment provider was not recognised or its token was invalid
|
||||
PEER_FLOOD,400,Too many requests
|
||||
PEER_HISTORY_EMPTY,400,
|
||||
PEER_ID_INVALID,400,"An invalid Peer was used. Make sure to pass the right peer type and that the value is valid (for instance, bots cannot start conversations)"
|
||||
PEER_ID_NOT_SUPPORTED,400,The provided peer ID is not supported
|
||||
PERSISTENT_TIMESTAMP_EMPTY,400,Persistent timestamp empty
|
||||
PERSISTENT_TIMESTAMP_INVALID,400,Persistent timestamp invalid
|
||||
PERSISTENT_TIMESTAMP_OUTDATED,500,Persistent timestamp outdated
|
||||
PHONE_CODE_EMPTY,400,The phone code is missing
|
||||
PHONE_CODE_EXPIRED,400,The confirmation code has expired
|
||||
PHONE_CODE_HASH_EMPTY,400,The phone code hash is missing
|
||||
PHONE_CODE_INVALID,400,The phone code entered was invalid
|
||||
PHONE_HASH_EXPIRED,400,An invalid or expired `phone_code_hash` was provided
|
||||
PHONE_MIGRATE_X,303,The phone number a user is trying to use for authorization is associated with DC {new_dc}
|
||||
PHONE_NOT_OCCUPIED,400,No user is associated to the specified phone number
|
||||
PHONE_NUMBER_APP_SIGNUP_FORBIDDEN,400,You can't sign up using this app
|
||||
PHONE_NUMBER_BANNED,400,The used phone number has been banned from Telegram and cannot be used anymore. Maybe check https://www.telegram.org/faq_spam
|
||||
PHONE_NUMBER_FLOOD,400,You asked for the code too many times.
|
||||
PHONE_NUMBER_INVALID,400 406,The phone number is invalid
|
||||
PHONE_NUMBER_OCCUPIED,400,The phone number is already in use
|
||||
PHONE_NUMBER_UNOCCUPIED,400,The phone number is not yet being used
|
||||
PHONE_PASSWORD_FLOOD,406,You have tried logging in too many times
|
||||
PHONE_PASSWORD_PROTECTED,400,This phone is password protected
|
||||
PHOTO_CONTENT_TYPE_INVALID,400,Photo mime-type invalid
|
||||
PHOTO_CONTENT_URL_EMPTY,400,The content from the URL used as a photo appears to be empty or has caused another HTTP error
|
||||
PHOTO_CROP_FILE_MISSING,400,Photo crop file missing
|
||||
PHOTO_CROP_SIZE_SMALL,400,Photo is too small
|
||||
PHOTO_EXT_INVALID,400,The extension of the photo is invalid
|
||||
PHOTO_FILE_MISSING,400,Profile photo file missing
|
||||
PHOTO_ID_INVALID,400,Photo id is invalid
|
||||
PHOTO_INVALID,400,Photo invalid
|
||||
PHOTO_INVALID_DIMENSIONS,400,The photo dimensions are invalid (hint: `pip install pillow` for `send_file` to resize images)
|
||||
PHOTO_SAVE_FILE_INVALID,400,The photo you tried to send cannot be saved by Telegram. A reason may be that it exceeds 10MB. Try resizing it locally
|
||||
PHOTO_THUMB_URL_EMPTY,400,The URL used as a thumbnail appears to be empty or has caused another HTTP error
|
||||
PINNED_DIALOGS_TOO_MUCH,400,Too many pinned dialogs
|
||||
PIN_RESTRICTED,400,You can't pin messages in private chats with other people
|
||||
POLL_ANSWERS_INVALID,400,The poll did not have enough answers or had too many
|
||||
POLL_ANSWER_INVALID,400,One of the poll answers is not acceptable
|
||||
POLL_OPTION_DUPLICATE,400,A duplicate option was sent in the same poll
|
||||
POLL_OPTION_INVALID,400,A poll option used invalid data (the data may be too long)
|
||||
POLL_QUESTION_INVALID,400,The poll question was either empty or too long
|
||||
POLL_UNSUPPORTED,400,This layer does not support polls in the issued method
|
||||
POLL_VOTE_REQUIRED,403,Cast a vote in the poll before calling this method
|
||||
POSTPONED_TIMEOUT,500,The postponed call has timed out
|
||||
PREMIUM_ACCOUNT_REQUIRED,403,A premium account is required to execute this action
|
||||
PREMIUM_CURRENTLY_UNAVAILABLE,406,
|
||||
PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_XMIN,406,"Similar to a flood wait, must wait {minutes} minutes"
|
||||
PRIVACY_KEY_INVALID,400,The privacy key is invalid
|
||||
PRIVACY_TOO_LONG,400,Cannot add that many entities in a single request
|
||||
PRIVACY_VALUE_INVALID,400,The privacy value is invalid
|
||||
PTS_CHANGE_EMPTY,500,No PTS change
|
||||
PUBLIC_CHANNEL_MISSING,403,You can only export group call invite links for public chats or channels
|
||||
PUBLIC_KEY_REQUIRED,400,A public key is required
|
||||
QUERY_ID_EMPTY,400,The query ID is empty
|
||||
QUERY_ID_INVALID,400,The query ID is invalid
|
||||
QUERY_TOO_SHORT,400,The query string is too short
|
||||
QUIZ_ANSWER_MISSING,400,You can forward a quiz while hiding the original author only after choosing an option in the quiz
|
||||
QUIZ_CORRECT_ANSWERS_EMPTY,400,A quiz must specify one correct answer
|
||||
QUIZ_CORRECT_ANSWERS_TOO_MUCH,400,There can only be one correct answer
|
||||
QUIZ_CORRECT_ANSWER_INVALID,400,The correct answer is not an existing answer
|
||||
QUIZ_MULTIPLE_INVALID,400,A poll cannot be both multiple choice and quiz
|
||||
RANDOM_ID_DUPLICATE,500,You provided a random ID that was already used
|
||||
RANDOM_ID_EMPTY,400,Random ID empty
|
||||
RANDOM_ID_INVALID,400,A provided random ID is invalid
|
||||
RANDOM_LENGTH_INVALID,400,Random length invalid
|
||||
RANGES_INVALID,400,Invalid range provided
|
||||
REACTIONS_TOO_MANY,400,"The message already has exactly `reactions_uniq_max` reaction emojis, you can't react with a new emoji, see [the docs for more info](/api/config#client-configuration)"
|
||||
REACTION_EMPTY,400,No reaction provided
|
||||
REACTION_INVALID,400,Invalid reaction provided (only emoji are allowed)
|
||||
REFLECTOR_NOT_AVAILABLE,400,Invalid call reflector server
|
||||
REG_ID_GENERATE_FAILED,500,Failure while generating registration ID
|
||||
REPLY_MARKUP_BUY_EMPTY,400,Reply markup for buy button empty
|
||||
REPLY_MARKUP_GAME_EMPTY,400,The provided reply markup for the game is empty
|
||||
REPLY_MARKUP_INVALID,400,The provided reply markup is invalid
|
||||
REPLY_MARKUP_TOO_LONG,400,The data embedded in the reply markup buttons was too much
|
||||
RESET_REQUEST_MISSING,400,No password reset is in progress
|
||||
RESULTS_TOO_MUCH,400,"You sent too many results, see https://core.telegram.org/bots/api#answerinlinequery for the current limit"
|
||||
RESULT_ID_DUPLICATE,400,Duplicated IDs on the sent results. Make sure to use unique IDs
|
||||
RESULT_ID_EMPTY,400,Result ID empty
|
||||
RESULT_ID_INVALID,400,The given result cannot be used to send the selection to the bot
|
||||
RESULT_TYPE_INVALID,400,Result type invalid
|
||||
REVOTE_NOT_ALLOWED,400,You cannot change your vote
|
||||
RIGHTS_NOT_MODIFIED,400,"The new admin rights are equal to the old rights, no change was made"
|
||||
RIGHT_FORBIDDEN,403,Either your admin rights do not allow you to do this or you passed the wrong rights combination (some rights only apply to channels and vice versa)
|
||||
RPC_CALL_FAIL,500,"Telegram is having internal issues, please try again later."
|
||||
RPC_MCGET_FAIL,500,"Telegram is having internal issues, please try again later."
|
||||
RSA_DECRYPT_FAILED,400,Internal RSA decryption failed
|
||||
SCHEDULE_BOT_NOT_ALLOWED,400,Bots are not allowed to schedule messages
|
||||
SCHEDULE_DATE_INVALID,400,Invalid schedule date provided
|
||||
SCHEDULE_DATE_TOO_LATE,400,The date you tried to schedule is too far in the future (last known limit of 1 year and a few hours)
|
||||
SCHEDULE_STATUS_PRIVATE,400,You cannot schedule a message until the person comes online if their privacy does not show this information
|
||||
SCHEDULE_TOO_MUCH,400,You cannot schedule more messages in this chat (last known limit of 100 per chat)
|
||||
SCORE_INVALID,400,The specified game score is invalid
|
||||
SEARCH_QUERY_EMPTY,400,The search query is empty
|
||||
SEARCH_WITH_LINK_NOT_SUPPORTED,400,You cannot provide a search query and an invite link at the same time
|
||||
SECONDS_INVALID,400,"Slow mode only supports certain values (e.g. 0, 10s, 30s, 1m, 5m, 15m and 1h)"
|
||||
SEND_AS_PEER_INVALID,400,You can't send messages as the specified peer
|
||||
SEND_CODE_UNAVAILABLE,406,"Returned when all available options for this type of number were already used (e.g. flash-call, then SMS, then this error might be returned to trigger a second resend)"
|
||||
SEND_MESSAGE_MEDIA_INVALID,400,The message media was invalid or not specified
|
||||
SEND_MESSAGE_TYPE_INVALID,400,The message type is invalid
|
||||
SENSITIVE_CHANGE_FORBIDDEN,403,Your sensitive content settings cannot be changed at this time
|
||||
SESSION_EXPIRED,401,The authorization has expired
|
||||
SESSION_PASSWORD_NEEDED,401,Two-steps verification is enabled and a password is required
|
||||
SESSION_REVOKED,401,"The authorization has been invalidated, because of the user terminating all sessions"
|
||||
SESSION_TOO_FRESH_X,400,The session logged in too recently and {seconds} seconds must pass before calling the method
|
||||
SETTINGS_INVALID,400,Invalid settings were provided
|
||||
SHA256_HASH_INVALID,400,The provided SHA256 hash is invalid
|
||||
SHORTNAME_OCCUPY_FAILED,400,An error occurred when trying to register the short-name used for the sticker pack. Try a different name
|
||||
SHORT_NAME_INVALID,400,The specified short name is invalid
|
||||
SHORT_NAME_OCCUPIED,400,The specified short name is already in use
|
||||
SIGN_IN_FAILED,500,Failure while signing in
|
||||
SLOWMODE_MULTI_MSGS_DISABLED,400,"Slowmode is enabled, you cannot forward multiple messages to this group"
|
||||
SLOWMODE_WAIT_X,420,A wait of {seconds} seconds is required before sending another message in this chat
|
||||
SMS_CODE_CREATE_FAILED,400,An error occurred while creating the SMS code
|
||||
SRP_ID_INVALID,400,Invalid SRP ID provided
|
||||
SRP_PASSWORD_CHANGED,400,Password has changed
|
||||
START_PARAM_EMPTY,400,The start parameter is empty
|
||||
START_PARAM_INVALID,400,Start parameter invalid
|
||||
START_PARAM_TOO_LONG,400,Start parameter is too long
|
||||
STATS_MIGRATE_X,303,The channel statistics must be fetched from DC {dc}
|
||||
STICKERPACK_STICKERS_TOO_MUCH,400,"There are too many stickers in this stickerpack, you can't add any more"
|
||||
STICKERSET_INVALID,400 406,The provided sticker set is invalid
|
||||
STICKERSET_OWNER_ANONYMOUS,406,This sticker set can't be used as the group's official stickers because it was created by one of its anonymous admins
|
||||
STICKERS_EMPTY,400,No sticker provided
|
||||
STICKERS_TOO_MUCH,400,"There are too many stickers in this stickerpack, you can't add any more"
|
||||
STICKER_DOCUMENT_INVALID,400,"The sticker file was invalid (this file has failed Telegram internal checks, make sure to use the correct format and comply with https://core.telegram.org/animated_stickers)"
|
||||
STICKER_EMOJI_INVALID,400,Sticker emoji invalid
|
||||
STICKER_FILE_INVALID,400,Sticker file invalid
|
||||
STICKER_GIF_DIMENSIONS,400,The specified video sticker has invalid dimensions
|
||||
STICKER_ID_INVALID,400,The provided sticker ID is invalid
|
||||
STICKER_INVALID,400,The provided sticker is invalid
|
||||
STICKER_MIME_INVALID,400,Make sure to pass a valid image file for the right InputFile parameter
|
||||
STICKER_PNG_DIMENSIONS,400,Sticker png dimensions invalid
|
||||
STICKER_PNG_NOPNG,400,Stickers must be a png file but the used image was not a png
|
||||
STICKER_TGS_NODOC,400,You must send the animated sticker as a document
|
||||
STICKER_TGS_NOTGS,400,Stickers must be a tgs file but the used file was not a tgs
|
||||
STICKER_THUMB_PNG_NOPNG,400,Stickerset thumb must be a png file but the used file was not png
|
||||
STICKER_THUMB_TGS_NOTGS,400,Stickerset thumb must be a tgs file but the used file was not tgs
|
||||
STICKER_VIDEO_BIG,400,The specified video sticker is too big
|
||||
STICKER_VIDEO_NODOC,400,You must send the video sticker as a document
|
||||
STICKER_VIDEO_NOWEBM,400,The specified video sticker is not in webm format
|
||||
STORAGE_CHECK_FAILED,500,Server storage check failed
|
||||
STORE_INVALID_SCALAR_TYPE,500,
|
||||
SWITCH_PM_TEXT_EMPTY,400,The switch_pm.text field was empty
|
||||
TAKEOUT_INIT_DELAY_X,420,A wait of {seconds} seconds is required before being able to initiate the takeout
|
||||
TAKEOUT_INVALID,400,The takeout session has been invalidated by another data export session
|
||||
TAKEOUT_REQUIRED,400 403,You must initialize a takeout request first
|
||||
TEMP_AUTH_KEY_ALREADY_BOUND,400,The passed temporary key is already bound to another **perm_auth_key_id**
|
||||
TEMP_AUTH_KEY_EMPTY,400,No temporary auth key provided
|
||||
THEME_FILE_INVALID,400,Invalid theme file provided
|
||||
THEME_FORMAT_INVALID,400,Invalid theme format provided
|
||||
THEME_INVALID,400,Theme invalid
|
||||
THEME_MIME_INVALID,400,"You cannot create this theme, the mime-type is invalid"
|
||||
THEME_TITLE_INVALID,400,The specified theme title is invalid
|
||||
TIMEOUT,500,A timeout occurred while fetching data from the worker
|
||||
TITLE_INVALID,400,The specified stickerpack title is invalid
|
||||
TMP_PASSWORD_DISABLED,400,The temporary password is disabled
|
||||
TMP_PASSWORD_INVALID,400,Password auth needs to be regenerated
|
||||
TOKEN_INVALID,400,The provided token is invalid
|
||||
TOPIC_DELETED,400,The topic was deleted
|
||||
TO_LANG_INVALID,400,The specified destination language is invalid
|
||||
TTL_DAYS_INVALID,400,The provided TTL is invalid
|
||||
TTL_MEDIA_INVALID,400,The provided media cannot be used with a TTL
|
||||
TTL_PERIOD_INVALID,400,The provided TTL Period is invalid
|
||||
TYPES_EMPTY,400,The types field is empty
|
||||
TYPE_CONSTRUCTOR_INVALID,400,The type constructor is invalid
|
||||
Timedout,-503,Timeout while fetching data
|
||||
Timeout,-503,Timeout while fetching data
|
||||
UNKNOWN_ERROR,400,
|
||||
UNKNOWN_METHOD,500,The method you tried to call cannot be called on non-CDN DCs
|
||||
UNTIL_DATE_INVALID,400,That date cannot be specified in this request (try using None)
|
||||
UPDATE_APP_TO_LOGIN,406,
|
||||
URL_INVALID,400,The URL used was invalid (e.g. when answering a callback with a URL that's not t.me/yourbot or your game's URL)
|
||||
USAGE_LIMIT_INVALID,400,The specified usage limit is invalid
|
||||
USERNAME_INVALID,400,"Nobody is using this username, or the username is unacceptable. If the latter, it must match r""[a-zA-Z][\w\d]{3,30}[a-zA-Z\d]"""
|
||||
USERNAME_NOT_MODIFIED,400,The username is not different from the current username
|
||||
USERNAME_NOT_OCCUPIED,400,The username is not in use by anyone else yet
|
||||
USERNAME_OCCUPIED,400,The username is already taken
|
||||
USERNAME_PURCHASE_AVAILABLE,400,
|
||||
USERPIC_PRIVACY_REQUIRED,406,You need to disable privacy settings for your profile picture in order to make your geolocation public
|
||||
USERPIC_UPLOAD_REQUIRED,400 406,You must have a profile picture before using this method
|
||||
USERS_TOO_FEW,400,"Not enough users (to create a chat, for example)"
|
||||
USERS_TOO_MUCH,400,"The maximum number of users has been exceeded (to create a chat, for example)"
|
||||
USER_ADMIN_INVALID,400,Either you're not an admin or you tried to ban an admin that you didn't promote
|
||||
USER_ALREADY_INVITED,400,You have already invited this user
|
||||
USER_ALREADY_PARTICIPANT,400,The authenticated user is already a participant of the chat
|
||||
USER_BANNED_IN_CHANNEL,400,You're banned from sending messages in supergroups/channels
|
||||
USER_BLOCKED,400,User blocked
|
||||
USER_BOT,400,Bots can only be admins in channels.
|
||||
USER_BOT_INVALID,400 403,This method can only be called by a bot
|
||||
USER_BOT_REQUIRED,400,This method can only be called by a bot
|
||||
USER_CHANNELS_TOO_MUCH,400 403,One of the users you tried to add is already in too many channels/supergroups
|
||||
USER_CREATOR,400,"You can't leave this channel, because you're its creator"
|
||||
USER_DEACTIVATED,401,The user has been deleted/deactivated
|
||||
USER_DEACTIVATED_BAN,401,The user has been deleted/deactivated
|
||||
USER_DELETED,403,You can't send this secret message because the other participant deleted their account
|
||||
USER_ID_INVALID,400,"Invalid object ID for a user. Make sure to pass the right types, for instance making sure that the request is designed for users or otherwise look for a different one more suited"
|
||||
USER_INVALID,400 403,The given user was invalid
|
||||
USER_IS_BLOCKED,400 403,User is blocked
|
||||
USER_IS_BOT,400,Bots can't send messages to other bots
|
||||
USER_KICKED,400,This user was kicked from this supergroup/channel
|
||||
USER_MIGRATE_X,303,The user whose identity is being used to execute queries is associated with DC {new_dc}
|
||||
USER_NOT_MUTUAL_CONTACT,400 403,The provided user is not a mutual contact
|
||||
USER_NOT_PARTICIPANT,400,The target user is not a member of the specified megagroup or channel
|
||||
USER_PRIVACY_RESTRICTED,403,The user's privacy settings do not allow you to do this
|
||||
USER_RESTRICTED,403 406,"You're spamreported, you can't create channels or chats."
|
||||
USER_VOLUME_INVALID,400,The specified user volume is invalid
|
||||
VIDEO_CONTENT_TYPE_INVALID,400,The video content type is not supported with the given parameters (i.e. supports_streaming)
|
||||
VIDEO_FILE_INVALID,400,The given video cannot be used
|
||||
VIDEO_TITLE_EMPTY,400,The specified video title is empty
|
||||
VOICE_MESSAGES_FORBIDDEN,400,This user's privacy settings forbid you from sending voice messages
|
||||
WALLPAPER_FILE_INVALID,400,The given file cannot be used as a wallpaper
|
||||
WALLPAPER_INVALID,400,The input wallpaper was not valid
|
||||
WALLPAPER_MIME_INVALID,400,The specified wallpaper MIME type is invalid
|
||||
WC_CONVERT_URL_INVALID,400,WC convert URL invalid
|
||||
WEBDOCUMENT_INVALID,400,Invalid webdocument URL provided
|
||||
WEBDOCUMENT_MIME_INVALID,400,Invalid webdocument mime type provided
|
||||
WEBDOCUMENT_SIZE_TOO_BIG,400,Webdocument is too big!
|
||||
WEBDOCUMENT_URL_INVALID,400,The given URL cannot be used
|
||||
WEBPAGE_CURL_FAILED,400,Failure while fetching the webpage with cURL
|
||||
WEBPAGE_MEDIA_EMPTY,400,Webpage media empty
|
||||
WEBPUSH_AUTH_INVALID,400,The specified web push authentication secret is invalid
|
||||
WEBPUSH_KEY_INVALID,400,The specified web push elliptic curve Diffie-Hellman public key is invalid
|
||||
WEBPUSH_TOKEN_INVALID,400,The specified web push token is invalid
|
||||
WORKER_BUSY_TOO_LONG_RETRY,500,Telegram workers are too busy to respond immediately
|
||||
YOU_BLOCKED_USER,400,You blocked this user
|
||||
FROZEN_METHOD_INVALID,420,You tried to use a method that is not available for frozen accounts
|
||||
FROZEN_PARTICIPANT_MISSING,400,Your account is frozen and can't access the chat
|
||||
|
@@ -0,0 +1,64 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.mau.fi/util/exerrors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
errorCSV := exerrors.Must(os.Open("gen/errors.csv"))
|
||||
reader := csv.NewReader(errorCSV)
|
||||
var data bytes.Buffer
|
||||
data.WriteString("package humanise\n")
|
||||
data.WriteString("import \"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr\"\n")
|
||||
data.WriteString("func Error(err error) string {\n")
|
||||
data.WriteString("switch {\n")
|
||||
for {
|
||||
row, err := reader.Read()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
data.WriteString(`case tgerr.Is(err, "`)
|
||||
data.WriteString(row[0])
|
||||
data.WriteString(`"): return "`)
|
||||
errString := strings.ReplaceAll(row[2], `\`, `\\`)
|
||||
errString = strings.ReplaceAll(errString, `"`, `\"`)
|
||||
data.WriteString(errString)
|
||||
data.WriteString(`"`)
|
||||
data.WriteString("\n")
|
||||
|
||||
fmt.Printf("row %+v\n", row)
|
||||
}
|
||||
data.WriteString("}\n")
|
||||
data.WriteString("return err.Error()")
|
||||
data.WriteString("}")
|
||||
|
||||
exerrors.PanicIfNotNil(os.WriteFile("errors.go", data.Bytes(), os.ModePerm))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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 humanise turns things into human-readable strings.
|
||||
package humanise
|
||||
|
||||
//go:generate go run ./gen
|
||||
//go:generate goimports -w errors.go
|
||||
@@ -0,0 +1,40 @@
|
||||
// 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 (
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
func (tc *TelegramClient) makePortalKeyFromPeer(peer tg.PeerClass, topicID int) networkid.PortalKey {
|
||||
key := ids.InternalPeerToPortalKey(peer, topicID, tc.loginID)
|
||||
if tc.main.Bridge.Config.SplitPortals {
|
||||
key.Receiver = tc.userLogin.ID
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) makePortalKeyFromID(peerType ids.PeerType, chatID int64, topicID int) networkid.PortalKey {
|
||||
key := ids.InternalMakePortalKey(peerType, chatID, topicID, tc.loginID)
|
||||
if tc.main.Bridge.Config.SplitPortals {
|
||||
key.Receiver = tc.userLogin.ID
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// 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 ids
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.mau.fi/util/variationselector"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
func MakeUserID(userID int64) networkid.UserID {
|
||||
if userID == 0 {
|
||||
return ""
|
||||
}
|
||||
return networkid.UserID(strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
func MakeChannelUserID(channelID int64) networkid.UserID {
|
||||
if channelID == 0 {
|
||||
return ""
|
||||
}
|
||||
return networkid.UserID("channel-" + strconv.FormatInt(channelID, 10))
|
||||
}
|
||||
|
||||
func ParseUserID(userID networkid.UserID) (PeerType, int64, error) {
|
||||
peerType := PeerTypeUser
|
||||
rawUserID := string(userID)
|
||||
if strings.HasPrefix(string(userID), "channel-") {
|
||||
peerType = PeerTypeChannel
|
||||
rawUserID = strings.TrimPrefix(rawUserID, "channel-")
|
||||
}
|
||||
id, err := strconv.ParseInt(rawUserID, 10, 64)
|
||||
return peerType, id, err
|
||||
}
|
||||
|
||||
func ParseUserLoginID(userID networkid.UserLoginID) (int64, error) {
|
||||
return strconv.ParseInt(string(userID), 10, 64)
|
||||
}
|
||||
|
||||
func UserLoginIDToUserID(userLoginID networkid.UserLoginID) networkid.UserID {
|
||||
return networkid.UserID(userLoginID)
|
||||
}
|
||||
|
||||
func MakeUserLoginID(userID int64) networkid.UserLoginID {
|
||||
if userID == 0 {
|
||||
return ""
|
||||
}
|
||||
return networkid.UserLoginID(strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
func GetMessageIDFromMessage(message tg.MessageClass) networkid.MessageID {
|
||||
var peer tg.PeerClass
|
||||
switch typedMsg := message.(type) {
|
||||
case *tg.MessageEmpty:
|
||||
peer, _ = typedMsg.GetPeerID()
|
||||
case *tg.Message:
|
||||
peer = typedMsg.GetPeerID()
|
||||
case *tg.MessageService:
|
||||
peer = typedMsg.GetPeerID()
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected message type %T", message))
|
||||
}
|
||||
return MakeMessageID(peer, message.GetID())
|
||||
}
|
||||
|
||||
func MakeMessageID(rawChatID any, messageID int) networkid.MessageID {
|
||||
var channelID int64
|
||||
switch typedChatID := rawChatID.(type) {
|
||||
case networkid.PortalKey:
|
||||
peerType, entityID, _, _ := ParsePortalID(typedChatID.ID)
|
||||
if peerType == PeerTypeChannel {
|
||||
channelID = entityID
|
||||
}
|
||||
case *tg.PeerChannel:
|
||||
channelID = typedChatID.ChannelID
|
||||
case int64:
|
||||
channelID = typedChatID
|
||||
case *tg.PeerUser, *tg.PeerChat:
|
||||
// No channel ID
|
||||
case nil:
|
||||
// Also no channel ID
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected chat ID type %T", rawChatID))
|
||||
}
|
||||
if channelID != 0 {
|
||||
return networkid.MessageID(fmt.Sprintf("%d.%d", channelID, messageID))
|
||||
}
|
||||
return networkid.MessageID(fmt.Sprintf("%d", messageID))
|
||||
}
|
||||
|
||||
func MakePaginationCursorID(messageID int) networkid.PaginationCursor {
|
||||
return networkid.PaginationCursor(strconv.Itoa(messageID))
|
||||
}
|
||||
|
||||
func ParseMessageID(networkID networkid.MessageID) (channelID int64, messageID int, err error) {
|
||||
parts := strings.Split(string(networkID), ".")
|
||||
if len(parts) == 1 {
|
||||
messageID, err = strconv.Atoi(parts[0])
|
||||
} else if len(parts) == 2 {
|
||||
channelID, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
return
|
||||
}
|
||||
messageID, err = strconv.Atoi(parts[1])
|
||||
} else {
|
||||
err = fmt.Errorf("invalid number of parts in message ID")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type PeerType string
|
||||
|
||||
const (
|
||||
PeerTypeUser PeerType = "user"
|
||||
PeerTypeChat PeerType = "chat"
|
||||
PeerTypeChannel PeerType = "channel"
|
||||
|
||||
FakePeerTypeEmoji PeerType = "emoji"
|
||||
)
|
||||
|
||||
func PeerTypeFromByte(pt byte) (PeerType, error) {
|
||||
switch pt {
|
||||
case 0x01:
|
||||
return PeerTypeUser, nil
|
||||
case 0x02:
|
||||
return PeerTypeChat, nil
|
||||
case 0x03:
|
||||
return PeerTypeChannel, nil
|
||||
case 0x04:
|
||||
return FakePeerTypeEmoji, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown peer type %d", pt)
|
||||
}
|
||||
}
|
||||
|
||||
func (pt PeerType) AsByte() byte {
|
||||
switch pt {
|
||||
case PeerTypeUser:
|
||||
return 0x01
|
||||
case PeerTypeChat:
|
||||
return 0x02
|
||||
case PeerTypeChannel:
|
||||
return 0x03
|
||||
case FakePeerTypeEmoji:
|
||||
return 0x04
|
||||
default:
|
||||
panic(fmt.Errorf("unknown peer type %s", pt))
|
||||
}
|
||||
}
|
||||
|
||||
func MakePortalID(pt PeerType, chatID int64) networkid.PortalID {
|
||||
return networkid.PortalID(fmt.Sprintf("%s:%d", pt, chatID))
|
||||
}
|
||||
|
||||
const TopicIDSpaceRoom = -1
|
||||
|
||||
func MakeForumParentPortalID(channelID int64) networkid.PortalID {
|
||||
return MakeTopicPortalID(channelID, TopicIDSpaceRoom)
|
||||
}
|
||||
|
||||
func MakeTopicPortalID(channelID int64, topicID int) networkid.PortalID {
|
||||
return networkid.PortalID(fmt.Sprintf("%s:%d:%d", PeerTypeChannel, channelID, topicID))
|
||||
}
|
||||
|
||||
func InternalMakePortalKey(pt PeerType, chatID int64, topicID int, receiver networkid.UserLoginID) networkid.PortalKey {
|
||||
portalKey := networkid.PortalKey{
|
||||
ID: MakePortalID(pt, chatID),
|
||||
}
|
||||
if pt == PeerTypeUser || pt == PeerTypeChat {
|
||||
portalKey.Receiver = receiver
|
||||
} else if topicID != 0 {
|
||||
portalKey.ID = MakeTopicPortalID(chatID, topicID)
|
||||
}
|
||||
return portalKey
|
||||
}
|
||||
|
||||
func InternalPeerToPortalKey(peer tg.PeerClass, topicID int, receiver networkid.UserLoginID) networkid.PortalKey {
|
||||
switch v := peer.(type) {
|
||||
case *tg.PeerUser:
|
||||
return InternalMakePortalKey(PeerTypeUser, v.UserID, topicID, receiver)
|
||||
case *tg.PeerChat:
|
||||
return InternalMakePortalKey(PeerTypeChat, v.ChatID, topicID, receiver)
|
||||
case *tg.PeerChannel:
|
||||
return InternalMakePortalKey(PeerTypeChannel, v.ChannelID, topicID, receiver)
|
||||
default:
|
||||
panic(fmt.Errorf("unknown peer class type %T", v))
|
||||
}
|
||||
}
|
||||
|
||||
func ParsePortalID(portalID networkid.PortalID) (pt PeerType, id int64, topicID int, err error) {
|
||||
parts := strings.Split(string(portalID), ":")
|
||||
pt = PeerType(parts[0])
|
||||
id, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if len(parts) == 3 && err == nil && pt == PeerTypeChannel {
|
||||
topicID, err = strconv.Atoi(parts[2])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func MakeAvatarID(photoID int64) networkid.AvatarID {
|
||||
return networkid.AvatarID(strconv.FormatInt(photoID, 10))
|
||||
}
|
||||
|
||||
func MakeEmojiIDFromDocumentID(documentID int64) networkid.EmojiID {
|
||||
return networkid.EmojiID(strconv.FormatInt(documentID, 10))
|
||||
}
|
||||
|
||||
func MakeEmojiIDFromEmoticon(emoji string) networkid.EmojiID {
|
||||
return networkid.EmojiID(variationselector.Remove(emoji))
|
||||
}
|
||||
|
||||
func isNumbers(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ParseEmojiID(emojiID networkid.EmojiID) (documentID int64, emoji string, err error) {
|
||||
if isNumbers(string(emojiID)) {
|
||||
documentID, err = strconv.ParseInt(string(emojiID), 10, 64)
|
||||
} else {
|
||||
emoji = string(emojiID)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// 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 ids
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
)
|
||||
|
||||
// DirectMediaInfo is the information that is encoded in the media ID when
|
||||
// using direct media.
|
||||
//
|
||||
// The format of the media ID is as follows (each character represents a single
|
||||
// byte, |'s added for clarity):
|
||||
//
|
||||
// v|p|cccccccc|rrrrrrrr|mmmmmmmm|T|MMMMMMMM
|
||||
//
|
||||
// v (int8) = binary encoding format version. Should be 0.
|
||||
// p (byte) = the peer type of the Telegram chat ID
|
||||
// cccccccc (int64) = the Telegram peer ID (big endian)
|
||||
// rrrrrrrr (int64) = the Telegram user ID (big endian)
|
||||
// mmmmmmmm (int64) = the Telegram message ID (big endian)
|
||||
// MMMMMMMM (int64) = the Telegram photo/file/document ID (big endian)
|
||||
// T (byte) = 0 or 1 depending on whether it's a thumbnail
|
||||
type DirectMediaInfo struct {
|
||||
// Type of PeerID
|
||||
PeerType PeerType
|
||||
|
||||
// Peer ID, may be channel, chat or user
|
||||
PeerID int64
|
||||
|
||||
// Telegram user ID of the client that downloads this media
|
||||
UserID int64
|
||||
|
||||
// Telegram message ID if related to a message
|
||||
MessageID int64
|
||||
|
||||
// Telegram photo/file/document ID, depends on PeerType
|
||||
ID int64
|
||||
|
||||
// Is this a thumbnail?
|
||||
Thumbnail bool
|
||||
}
|
||||
|
||||
func (m DirectMediaInfo) AsMediaID() (networkid.MediaID, error) {
|
||||
var mediaID networkid.MediaID
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
// version byte
|
||||
if err := binary.Write(buf, binary.BigEndian, byte(0)); err != nil {
|
||||
return mediaID, err
|
||||
}
|
||||
|
||||
// v0
|
||||
if err := binary.Write(buf, binary.BigEndian, m.PeerType.AsByte()); err != nil {
|
||||
return mediaID, err
|
||||
} else if err := binary.Write(buf, binary.BigEndian, m.PeerID); err != nil {
|
||||
return mediaID, err
|
||||
} else if err := binary.Write(buf, binary.BigEndian, m.UserID); err != nil {
|
||||
return mediaID, err
|
||||
} else if err := binary.Write(buf, binary.BigEndian, m.MessageID); err != nil {
|
||||
return mediaID, err
|
||||
} else if err := binary.Write(buf, binary.BigEndian, m.ID); err != nil {
|
||||
return mediaID, err
|
||||
} else if err := binary.Write(buf, binary.BigEndian, m.Thumbnail); err != nil {
|
||||
return mediaID, err
|
||||
}
|
||||
|
||||
return networkid.MediaID(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
func ParseDirectMediaInfo(mediaID networkid.MediaID) (info DirectMediaInfo, err error) {
|
||||
if len(mediaID) == 0 {
|
||||
return info, fmt.Errorf("empty media ID")
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(mediaID)
|
||||
|
||||
// version byte
|
||||
var version byte
|
||||
if err := binary.Read(buf, binary.BigEndian, &version); err != nil {
|
||||
return info, err
|
||||
} else if version != 0 {
|
||||
return info, fmt.Errorf("invalid version %d", version)
|
||||
}
|
||||
|
||||
// v0
|
||||
var peerType byte
|
||||
if err := binary.Read(buf, binary.BigEndian, &peerType); err != nil {
|
||||
return info, fmt.Errorf("failed to read peer type: %w", err)
|
||||
} else if info.PeerType, err = PeerTypeFromByte(peerType); err != nil {
|
||||
return info, fmt.Errorf("failed to convert peer type: %w", err)
|
||||
} else if err := binary.Read(buf, binary.BigEndian, &info.PeerID); err != nil {
|
||||
return info, fmt.Errorf("failed to read peer id: %w", err)
|
||||
} else if err := binary.Read(buf, binary.BigEndian, &info.UserID); err != nil {
|
||||
return info, fmt.Errorf("failed to read user id: %w", err)
|
||||
} else if err := binary.Read(buf, binary.BigEndian, &info.MessageID); err != nil {
|
||||
return info, fmt.Errorf("failed to message id: %w", err)
|
||||
} else if err := binary.Read(buf, binary.BigEndian, &info.ID); err != nil {
|
||||
return info, fmt.Errorf("failed to media id: %w", err)
|
||||
} else if err := binary.Read(buf, binary.BigEndian, &info.Thumbnail); err != nil {
|
||||
return info, fmt.Errorf("failed to thumbnail flag: %w", err)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func HashMediaID(mediaID networkid.MediaID) [32]byte {
|
||||
return sha256.Sum256(mediaID)
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||
// 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 (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/emojishortcodes"
|
||||
"go.mau.fi/util/exmaps"
|
||||
"go.mau.fi/util/ffmpeg"
|
||||
"go.mau.fi/util/variationselector"
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/commands"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/media"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/store"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/uploader"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
|
||||
)
|
||||
|
||||
func (tc *TelegramClient) fnListEmojiPacks(ce *commands.Event) {
|
||||
resp, err := tc.client.API().MessagesGetAllStickers(ce.Ctx, 0)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to list image packs: %v", err)
|
||||
return
|
||||
}
|
||||
casted, ok := resp.(*tg.MessagesAllStickers)
|
||||
if !ok {
|
||||
ce.Reply("Unexpected response type: %T", resp)
|
||||
return
|
||||
}
|
||||
lines := make([]string, len(casted.Sets))
|
||||
for i, set := range casted.Sets {
|
||||
packType := "stickers"
|
||||
if set.Emojis {
|
||||
packType = "emojis"
|
||||
}
|
||||
lines[i] = fmt.Sprintf(
|
||||
"* %s (%s, %s)",
|
||||
format.EscapeMarkdown(set.Title),
|
||||
packType,
|
||||
format.SafeMarkdownCode(set.ShortName),
|
||||
)
|
||||
}
|
||||
ce.Reply("Your packs:\n\n%s", strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) fnUploadEmojiPack(ce *commands.Event) {
|
||||
if len(ce.Args) < 3 || !strings.HasPrefix(ce.Args[1], "!") {
|
||||
ce.Reply("Usage: `$cmdprefix emoji-pack upload <telegram shortcode> <room ID> <state key>`")
|
||||
return
|
||||
}
|
||||
dbl := ce.User.DoublePuppet(ce.Ctx)
|
||||
if dbl == nil {
|
||||
ce.Reply("Double puppeting is required to fetch emoji packs from Matrix")
|
||||
return
|
||||
}
|
||||
mx, ok := dbl.(bridgev2.MatrixAPIWithArbitraryRoomState)
|
||||
if !ok {
|
||||
ce.Reply("Matrix connector does not implement required interface")
|
||||
return
|
||||
}
|
||||
tgPackShortcode := ce.Args[0]
|
||||
roomID := id.RoomID(ce.Args[1])
|
||||
packStateKey := strings.Join(ce.Args[2:], " ")
|
||||
err := tc.main.Bridge.Bot.EnsureJoined(ce.Ctx, roomID)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to join room: %v", err)
|
||||
return
|
||||
}
|
||||
evt, err := mx.GetStateEvent(ce.Ctx, roomID, event.Type{Type: "im.ponies.room_emotes", Class: event.StateEventType}, packStateKey)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to get state event: %v", err)
|
||||
return
|
||||
}
|
||||
pack, ok := evt.Content.Parsed.(*event.ImagePackEventContent)
|
||||
if !ok {
|
||||
ce.Reply("Unexpected parsed content type %T", evt.Content.Parsed)
|
||||
return
|
||||
}
|
||||
evtID := ce.React("\u23f3\ufe0f")
|
||||
defer redactReaction(ce, evtID)
|
||||
link, err := tc.synchronizeEmojiPack(ce.Ctx, ce, pack, tgPackShortcode)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to synchronize emoji pack: %v", err)
|
||||
return
|
||||
}
|
||||
ce.Reply("Successfully synchronized %s", link)
|
||||
}
|
||||
|
||||
func resizeEmoji(src image.Image, size int) *image.RGBA {
|
||||
resized := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
bounds := src.Bounds()
|
||||
srcW, srcH := bounds.Dx(), bounds.Dy()
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return resized
|
||||
}
|
||||
|
||||
dstW, dstH := size, size
|
||||
if srcW > srcH {
|
||||
dstH = srcH * size / srcW
|
||||
if dstH < 1 {
|
||||
dstH = 1
|
||||
}
|
||||
} else if srcH > srcW {
|
||||
dstW = srcW * size / srcH
|
||||
if dstW < 1 {
|
||||
dstW = 1
|
||||
}
|
||||
}
|
||||
|
||||
left := (size - dstW) / 2
|
||||
top := (size - dstH) / 2
|
||||
dstRect := image.Rect(left, top, left+dstW, top+dstH)
|
||||
draw.BiLinear.Scale(resized, dstRect, src, bounds, draw.Over, nil)
|
||||
return resized
|
||||
}
|
||||
|
||||
func resizeSticker(src image.Image, maxSide int) *image.RGBA {
|
||||
var dstW, dstH int
|
||||
bounds := src.Bounds()
|
||||
srcW, srcH := bounds.Dx(), bounds.Dy()
|
||||
if srcW == srcH {
|
||||
dstW = maxSide
|
||||
dstH = maxSide
|
||||
} else if srcW > srcH {
|
||||
dstW = maxSide
|
||||
dstH = srcH * maxSide / srcW
|
||||
} else {
|
||||
dstH = maxSide
|
||||
dstW = srcW * maxSide / srcH
|
||||
}
|
||||
resized := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
draw.BiLinear.Scale(resized, resized.Bounds(), src, bounds, draw.Over, nil)
|
||||
return resized
|
||||
}
|
||||
|
||||
func reencodeImage(data []byte, resizer func(image.Image, int) *image.RGBA, size int) ([]byte, string, error) {
|
||||
decoded, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = png.Encode(&buf, resizer(decoded, size))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to re-encode image: %w", err)
|
||||
}
|
||||
return buf.Bytes(), "image/png", nil
|
||||
}
|
||||
|
||||
func convertGIFToWebM(ctx context.Context, data []byte, scaleFilter string) ([]byte, string, error) {
|
||||
if !ffmpeg.Supported() {
|
||||
return nil, "", fmt.Errorf("ffmpeg is not available")
|
||||
}
|
||||
webmData, err := ffmpeg.ConvertBytes(ctx, data, ".webm", nil, []string{
|
||||
"-vf", scaleFilter,
|
||||
"-c:v", "libvpx-vp9",
|
||||
"-pix_fmt", "yuva420p",
|
||||
"-t", "3",
|
||||
"-f", "webm",
|
||||
}, "image/gif")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to convert gif to webm: %w", err)
|
||||
}
|
||||
return webmData, "video/webm", nil
|
||||
}
|
||||
|
||||
func normalizeImage(ctx context.Context, data []byte, info *event.FileInfo, emoji bool) (convertedData []byte, convertedMime string, err error) {
|
||||
if emoji {
|
||||
if info.MimeType == "image/gif" {
|
||||
return convertGIFToWebM(ctx, data, "fps=fps='min(source_fps,30)',scale=100:100:force_original_aspect_ratio=decrease:flags=lanczos,pad=100:100:(ow-iw)/2:(oh-ih)/2:color=0x00000000")
|
||||
}
|
||||
if info.Width == 100 && info.Height == 100 {
|
||||
return data, info.MimeType, nil
|
||||
}
|
||||
return reencodeImage(data, resizeEmoji, 100)
|
||||
} else {
|
||||
if info.Width == 512 || info.Height == 512 {
|
||||
return data, info.MimeType, nil
|
||||
}
|
||||
if info.MimeType == "image/gif" {
|
||||
return convertGIFToWebM(ctx, data, "fps=fps='min(source_fps,30)',scale=512:512:force_original_aspect_ratio=decrease:flags=lanczos")
|
||||
}
|
||||
return reencodeImage(data, resizeSticker, 512)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) synchronizeEmoji(
|
||||
ctx context.Context, shortcode string, img *event.ImagePackImage, emoji bool,
|
||||
) (*tg.InputStickerSetItem, func(int64) error, error) {
|
||||
data, err := tc.main.Bridge.Bot.DownloadMedia(ctx, img.URL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to download %s (%s): %w", shortcode, img.URL, err)
|
||||
}
|
||||
if img.Info == nil {
|
||||
img.Info = &event.FileInfo{}
|
||||
}
|
||||
if img.Info.MimeType == "" {
|
||||
img.Info.MimeType = http.DetectContentType(data)
|
||||
}
|
||||
origWidth, origHeight := img.Info.Width, img.Info.Height
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to decode image config for %s: %w", shortcode, err)
|
||||
}
|
||||
img.Info.Width = cfg.Width
|
||||
img.Info.Height = cfg.Height
|
||||
if origWidth == 0 || origHeight == 0 {
|
||||
origWidth, origHeight = cfg.Width, cfg.Height
|
||||
}
|
||||
data, mime, err := normalizeImage(ctx, data, img.Info, emoji)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to normalize image for %s: %w", shortcode, err)
|
||||
}
|
||||
up, err := uploader.NewUploader(tc.client.API()).FromBytes(ctx, "", data)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to reupload %s: %w", shortcode, err)
|
||||
}
|
||||
uploaded, err := tc.client.API().MessagesUploadMedia(ctx, &tg.MessagesUploadMediaRequest{
|
||||
Media: &tg.InputMediaUploadedDocument{
|
||||
File: up,
|
||||
ForceFile: true,
|
||||
MimeType: mime,
|
||||
},
|
||||
Peer: &tg.InputPeerSelf{},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to finalize reuploaded media for %s: %w", shortcode, err)
|
||||
}
|
||||
doc, ok := uploaded.(*tg.MessageMediaDocument)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("unexpected uploaded media type %T for %s", uploaded, shortcode)
|
||||
}
|
||||
fakeDoc, ok := doc.Document.(*tg.Document)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("unexpected document type %T for %s", doc.Document, shortcode)
|
||||
}
|
||||
cacheRealDoc := func(realDocID int64) error {
|
||||
if realDocID == 0 {
|
||||
return fmt.Errorf("failed to get real document ID for %s/%d", shortcode, fakeDoc.ID)
|
||||
}
|
||||
err = tc.main.Store.TelegramFile.Insert(ctx, &store.TelegramFile{
|
||||
LocationID: store.TelegramFileLocationID(strconv.FormatInt(realDocID, 10)),
|
||||
MXC: img.URL,
|
||||
MIMEType: img.Info.MimeType,
|
||||
Size: len(data),
|
||||
Width: origWidth,
|
||||
Height: origHeight,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cache mxc for %s/%d: %w", shortcode, realDocID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &tg.InputStickerSetItem{
|
||||
Document: fakeDoc.AsInput(),
|
||||
Emoji: "\u2728\ufe0f",
|
||||
Keywords: shortcode,
|
||||
}, cacheRealDoc, nil
|
||||
}
|
||||
|
||||
func extractNewDocID(oldSet tg.MessagesStickerSetClass, newSetBox tg.MessagesStickerSetClass) int64 {
|
||||
newSet, ok := newSetBox.(*tg.MessagesStickerSet)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
oldDocIDs := make(exmaps.Set[int64])
|
||||
if oldSet != nil {
|
||||
for _, doc := range oldSet.(*tg.MessagesStickerSet).Documents {
|
||||
oldDocIDs.Add(doc.GetID())
|
||||
}
|
||||
}
|
||||
var found int64
|
||||
for _, doc := range newSet.Documents {
|
||||
if !oldDocIDs.Has(doc.GetID()) {
|
||||
if found == 0 {
|
||||
found = doc.GetID()
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) synchronizeEmojiPack(ctx context.Context, ce *commands.Event, pack *event.ImagePackEventContent, packShortcode string) (string, error) {
|
||||
resp, err := tc.client.API().StickersCheckShortName(ctx, packShortcode)
|
||||
if err != nil && !tgerr.Is(err, tg.ErrShortNameOccupied) {
|
||||
return "", fmt.Errorf("failed to check if shortcode is available: %w", err)
|
||||
}
|
||||
isEmojiPack := slices.Contains(pack.Metadata.Usage, event.ImagePackUsageEmoji) || len(pack.Metadata.Usage) == 0
|
||||
var rawSet tg.MessagesStickerSetClass
|
||||
if resp {
|
||||
var shortcode string
|
||||
var img *event.ImagePackImage
|
||||
for shortcode, img = range pack.Images {
|
||||
break
|
||||
}
|
||||
if img == nil {
|
||||
return "", fmt.Errorf("pack must contain at least one image")
|
||||
}
|
||||
item, saveCache, err := tc.synchronizeEmoji(ctx, shortcode, img, isEmojiPack)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to synchronize emoji %s: %w", shortcode, err)
|
||||
}
|
||||
rawSet, err = tc.client.API().StickersCreateStickerSet(ctx, &tg.StickersCreateStickerSetRequest{
|
||||
Emojis: isEmojiPack,
|
||||
UserID: &tg.InputUserSelf{},
|
||||
Title: cmp.Or(pack.Metadata.DisplayName, packShortcode),
|
||||
ShortName: packShortcode,
|
||||
Stickers: []tg.InputStickerSetItem{*item},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create pack: %w", err)
|
||||
}
|
||||
err = saveCache(extractNewDocID(nil, rawSet))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to cache document ID for new pack: %w", err)
|
||||
}
|
||||
} else {
|
||||
rawSet, err = tc.client.API().MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{
|
||||
Stickerset: &tg.InputStickerSetShortName{ShortName: packShortcode},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get pack: %w", err)
|
||||
}
|
||||
}
|
||||
set, ok := rawSet.(*tg.MessagesStickerSet)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected set type %T", rawSet)
|
||||
}
|
||||
if !set.Set.Creator {
|
||||
return "", fmt.Errorf("set %s was created by someone else", packShortcode)
|
||||
}
|
||||
isEmojiPack = set.Set.Emojis
|
||||
inputSet := &tg.InputStickerSetID{
|
||||
ID: set.Set.ID,
|
||||
AccessHash: set.Set.AccessHash,
|
||||
}
|
||||
deletedMXCs := make(map[id.ContentURIString]*tg.InputDocument, len(set.Documents))
|
||||
existingMXCs := make(exmaps.Set[id.ContentURIString], len(set.Documents))
|
||||
for _, doc := range set.Documents {
|
||||
file, err := tc.main.Store.TelegramFile.GetByLocationID(ctx, store.TelegramFileLocationID(strconv.FormatInt(doc.GetID(), 10)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get cached file for doc %d: %w", doc.GetID(), err)
|
||||
} else if file != nil {
|
||||
deletedMXCs[file.MXC] = doc.(*tg.Document).AsInput()
|
||||
existingMXCs.Add(file.MXC)
|
||||
}
|
||||
}
|
||||
for shortcode, img := range pack.Images {
|
||||
if existingMXCs.Has(img.URL) {
|
||||
delete(deletedMXCs, img.URL)
|
||||
continue
|
||||
}
|
||||
existingMXCs.Add(img.URL)
|
||||
item, saveCache, err := tc.synchronizeEmoji(ctx, shortcode, img, isEmojiPack)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to reupload %s: %v", shortcode, err)
|
||||
continue
|
||||
}
|
||||
rawNewSet, err := tc.client.API().StickersAddStickerToSet(ctx, &tg.StickersAddStickerToSetRequest{
|
||||
Stickerset: inputSet,
|
||||
Sticker: *item,
|
||||
})
|
||||
if err != nil {
|
||||
if tgerr.Is(err, tg.ErrStickerpackStickersTooMuch) || tgerr.Is(err, tg.ErrStickersTooMuch) {
|
||||
return "", err
|
||||
}
|
||||
ce.Reply("Failed to add %s/%d to pack: %v", shortcode, item.Document.(*tg.InputDocument).ID, err)
|
||||
continue
|
||||
}
|
||||
err = saveCache(extractNewDocID(rawSet, rawNewSet))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to cache document ID for new pack: %w", err)
|
||||
}
|
||||
rawSet = rawNewSet
|
||||
}
|
||||
for mxc, inputDoc := range deletedMXCs {
|
||||
_, err = tc.client.API().StickersRemoveStickerFromSet(ctx, inputDoc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to remove %s/%d from set: %w", mxc, inputDoc.ID, err)
|
||||
}
|
||||
}
|
||||
linktype := "addstickers"
|
||||
if isEmojiPack {
|
||||
linktype = "addemoji"
|
||||
}
|
||||
return fmt.Sprintf("https://t.me/%s/%s", linktype, set.Set.ShortName), nil
|
||||
}
|
||||
|
||||
var addStickersRegex = regexp.MustCompile(`^(?:(?:https?://)?(?:t|telegram)\.(?:me|dog)/(?:addstickers|addemoji)/)?([A-Za-z0-9-_]+)(?:\.json)?$`)
|
||||
var packShortcodeRegex = regexp.MustCompile(`^[A-Za-z0-9-_]+$`)
|
||||
|
||||
func redactReaction(ce *commands.Event, evtID id.EventID) {
|
||||
if evtID == "" {
|
||||
return
|
||||
}
|
||||
_, _ = ce.Bot.SendMessage(ce.Ctx, ce.OrigRoomID, event.EventRedaction, &event.Content{
|
||||
Parsed: &event.RedactionEventContent{
|
||||
Redacts: evtID,
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) fnDownloadEmojiPack(ce *commands.Event) {
|
||||
if len(ce.Args) == 0 {
|
||||
ce.Reply("Usage: `$cmdprefix emoji-pack download <pack shortcode or link>`")
|
||||
return
|
||||
}
|
||||
spaceRoom, err := tc.userLogin.GetSpaceRoom(ce.Ctx)
|
||||
if err != nil {
|
||||
ce.Reply("Failed to get space room: %v", err)
|
||||
return
|
||||
} else if spaceRoom == "" {
|
||||
ce.Reply("Can't bridge image packs if personal filtering spaces are disabled")
|
||||
return
|
||||
}
|
||||
evtID := ce.React("\u23f3\ufe0f")
|
||||
defer redactReaction(ce, evtID)
|
||||
pack, err := tc.DownloadImagePack(ce.Ctx, ce.Args[0])
|
||||
if err != nil {
|
||||
ce.Reply("Failed to import pack: %v", err)
|
||||
return
|
||||
}
|
||||
if pack.Shortcode == "" && pack.Content.Metadata.BridgedPack != nil {
|
||||
pack.Shortcode = pack.Content.Metadata.BridgedPack.URL
|
||||
}
|
||||
_, err = tc.main.Bridge.Bot.SendState(ce.Ctx, spaceRoom, event.StateUnstableImagePack, pack.Shortcode, &event.Content{
|
||||
Parsed: pack.Content,
|
||||
Raw: pack.Extra,
|
||||
}, time.Now())
|
||||
if err != nil {
|
||||
ce.Reply("Failed to send image pack to space: %v", err)
|
||||
} else {
|
||||
ce.Reply(
|
||||
"Successfully bridged image pack to %s",
|
||||
format.MarkdownLink("your personal filtering space",
|
||||
spaceRoom.URI(tc.main.Bridge.Matrix.ServerName()).MatrixToURL()))
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) {
|
||||
resp, err := tc.client.API().MessagesGetAllStickers(ctx, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
casted, ok := resp.(*tg.MessagesAllStickers)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response type: %T", resp)
|
||||
}
|
||||
packs := make([]*event.ImagePackMetadata, len(casted.Sets))
|
||||
for i, set := range casted.Sets {
|
||||
packs[i] = tc.makeImagePackMetadata(ctx, set)
|
||||
}
|
||||
return packs, nil
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) makeImagePackMetadata(ctx context.Context, pack tg.StickerSet) *event.ImagePackMetadata {
|
||||
linkType := "addstickers"
|
||||
usage := event.ImagePackUsageSticker
|
||||
if pack.Emojis {
|
||||
linkType = "addemoji"
|
||||
usage = event.ImagePackUsageEmoji
|
||||
}
|
||||
packURL := fmt.Sprintf("https://t.me/%s/%s", linkType, pack.ShortName)
|
||||
return &event.ImagePackMetadata{
|
||||
DisplayName: pack.Title,
|
||||
AvatarURL: "", // TODO
|
||||
Usage: []event.ImagePackUsage{usage},
|
||||
Attribution: fmt.Sprintf("Imported from %s", packURL),
|
||||
BridgedPack: &event.BridgedStickerPack{
|
||||
Network: StickerSourceID,
|
||||
URL: packURL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) DownloadImagePack(ctx context.Context, url string) (*bridgev2.ImportedImagePack, error) {
|
||||
var shortName string
|
||||
if match := addStickersRegex.FindStringSubmatch(url); match != nil {
|
||||
shortName = match[1]
|
||||
} else if packShortcodeRegex.MatchString(url) {
|
||||
shortName = url
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid pack shortcode or link: %s", url)
|
||||
}
|
||||
rawSet, err := tc.client.API().MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{Stickerset: &tg.InputStickerSetShortName{ShortName: shortName}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set, ok := rawSet.(*tg.MessagesStickerSet)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response type: %T", rawSet)
|
||||
}
|
||||
tc.addStickerPackToCache(set, true)
|
||||
pack := &event.ImagePackEventContent{
|
||||
Images: make(map[string]*event.ImagePackImage, len(set.Documents)),
|
||||
Metadata: *tc.makeImagePackMetadata(ctx, set.Set),
|
||||
}
|
||||
topLevelExtra := map[string]any{
|
||||
"fi.mau.telegram.stickerpack": map[string]any{
|
||||
"id": strconv.FormatInt(set.Set.ID, 10),
|
||||
"short_name": set.Set.ShortName,
|
||||
"emoji_pack": set.Set.Emojis,
|
||||
},
|
||||
}
|
||||
keywords := make(map[int64][]string)
|
||||
emojiLists := make(map[int64][]string)
|
||||
for _, kw := range set.Keywords {
|
||||
keywords[kw.DocumentID] = kw.Keyword
|
||||
}
|
||||
for _, emojiPack := range set.Packs {
|
||||
emoji := variationselector.Add(emojiPack.Emoticon)
|
||||
for _, doc := range emojiPack.Documents {
|
||||
emojiLists[doc] = append(emojiLists[doc], emoji)
|
||||
}
|
||||
}
|
||||
for i, rawDoc := range set.Documents {
|
||||
// TODO use direct media
|
||||
mxc, _, info, err := media.NewTransferer(tc.client.API()).
|
||||
WithStickerConfig(tc.main.Config.AnimatedSticker).
|
||||
WithForceWebmStickerConvert(set.Set.Emojis).
|
||||
WithDocument(rawDoc, false).
|
||||
Transfer(ctx, tc.main.Store, tc.main.Bridge.Bot)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer image in pack")
|
||||
return nil, fmt.Errorf("failed to transfer document %d: %w", rawDoc.GetID(), err)
|
||||
}
|
||||
kws := keywords[rawDoc.GetID()]
|
||||
imageEmojis := emojiLists[rawDoc.GetID()]
|
||||
var key string
|
||||
for _, kw := range kws {
|
||||
_, alreadySet := pack.Images[kw]
|
||||
if alreadySet {
|
||||
continue
|
||||
}
|
||||
key = kw
|
||||
break
|
||||
}
|
||||
var firstShortcode string
|
||||
if key == "" {
|
||||
for _, emoji := range imageEmojis {
|
||||
shortcode := emojishortcodes.Get(emoji)
|
||||
if shortcode == "" {
|
||||
continue
|
||||
}
|
||||
shortcode = fmt.Sprintf("%s_%s", set.Set.ShortName, shortcode)
|
||||
if firstShortcode == "" {
|
||||
firstShortcode = shortcode
|
||||
}
|
||||
_, alreadySet := pack.Images[shortcode]
|
||||
if alreadySet {
|
||||
continue
|
||||
}
|
||||
key = shortcode
|
||||
break
|
||||
}
|
||||
}
|
||||
if key == "" && firstShortcode != "" {
|
||||
for i := 2; i < 10000; i++ {
|
||||
kw := fmt.Sprintf("%s%d", firstShortcode, i)
|
||||
_, alreadySet := pack.Images[kw]
|
||||
if alreadySet {
|
||||
continue
|
||||
}
|
||||
key = kw
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
key = fmt.Sprintf("%s_img%d", set.Set.ShortName, i+1)
|
||||
}
|
||||
var emoji string
|
||||
if len(imageEmojis) > 0 {
|
||||
emoji = imageEmojis[0]
|
||||
}
|
||||
if !set.Set.Emojis {
|
||||
// Stickers need extra info in each sticker so they can be accurately bridged back to Telegram
|
||||
// Custom emojis don't have space for such info and can be used with just the document ID
|
||||
info.BridgedSticker = &event.BridgedSticker{
|
||||
Network: StickerSourceID,
|
||||
ID: strconv.FormatInt(rawDoc.GetID(), 10),
|
||||
PackURL: StickerPackURLPrefix + set.Set.ShortName,
|
||||
Emoji: emoji,
|
||||
}
|
||||
}
|
||||
pack.Images[key] = &event.ImagePackImage{
|
||||
URL: mxc,
|
||||
Body: cmp.Or(emoji, key),
|
||||
Info: info,
|
||||
}
|
||||
}
|
||||
return &bridgev2.ImportedImagePack{
|
||||
Content: pack,
|
||||
Extra: topLevelExtra,
|
||||
Shortcode: set.Set.ShortName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const StickerSourceID = "telegram"
|
||||
const StickerPackURLPrefix = "https://t.me/addstickers/"
|
||||
|
||||
func (tc *TelegramClient) stickerSourceFromAttribute(ctx context.Context, documentID int64, attr *tg.DocumentAttributeSticker) *event.BridgedSticker {
|
||||
var shortName string
|
||||
switch set := attr.Stickerset.(type) {
|
||||
case *tg.InputStickerSetID:
|
||||
pack, err := tc.GetCachedStickerPack(ctx, "", set, false)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Debug().Err(err).
|
||||
Int64("pack_id", set.ID).
|
||||
Msg("Failed to get sticker pack by ID to fill info")
|
||||
return nil
|
||||
}
|
||||
shortName = pack.meta.ShortName
|
||||
case *tg.InputStickerSetShortName:
|
||||
shortName = set.ShortName
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &event.BridgedSticker{
|
||||
Network: StickerSourceID,
|
||||
ID: strconv.FormatInt(documentID, 10),
|
||||
Emoji: attr.Alt,
|
||||
PackURL: StickerPackURLPrefix + shortName,
|
||||
}
|
||||
}
|
||||
|
||||
type stickerPackCache struct {
|
||||
docs map[int64]*tg.Document
|
||||
meta tg.StickerSet
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) GetCachedStickerPack(ctx context.Context, shortName string, id *tg.InputStickerSetID, forceClearCache bool) (*stickerPackCache, error) {
|
||||
tc.stickerPackCacheLock.Lock()
|
||||
defer tc.stickerPackCacheLock.Unlock()
|
||||
cacheName := strings.ToLower(shortName)
|
||||
cache, ok := tc.stickerPacksByName[cacheName]
|
||||
if !ok {
|
||||
cache, ok = tc.stickerPacksByID[id.GetID()]
|
||||
}
|
||||
if !ok || forceClearCache {
|
||||
var inputSet tg.InputStickerSetClass = id
|
||||
if id == nil {
|
||||
inputSet = &tg.InputStickerSetShortName{ShortName: shortName}
|
||||
}
|
||||
resp, err := tc.client.API().MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{Stickerset: inputSet})
|
||||
if err != nil {
|
||||
if tgerr.Is(err, tg.ErrStickersetInvalid) {
|
||||
if cacheName != "" {
|
||||
tc.stickerPacksByName[cacheName] = nil
|
||||
}
|
||||
if id != nil {
|
||||
tc.stickerPacksByID[id.GetID()] = nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get sticker set: %w", err)
|
||||
}
|
||||
set, ok := resp.AsModified()
|
||||
if !ok {
|
||||
if cacheName != "" {
|
||||
tc.stickerPacksByName[cacheName] = nil
|
||||
}
|
||||
if id != nil {
|
||||
tc.stickerPacksByID[id.GetID()] = nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected response type for MessagesGetStickerSet: %T", resp)
|
||||
}
|
||||
cache = tc.addStickerPackToCache(set, false)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) addStickerPackToCache(set *tg.MessagesStickerSet, lock bool) *stickerPackCache {
|
||||
if lock {
|
||||
tc.stickerPackCacheLock.Lock()
|
||||
defer tc.stickerPackCacheLock.Unlock()
|
||||
}
|
||||
cache := &stickerPackCache{
|
||||
docs: set.MapDocuments().DocumentToMap(),
|
||||
meta: set.Set,
|
||||
}
|
||||
tc.stickerPacksByName[strings.ToLower(set.Set.ShortName)] = cache
|
||||
tc.stickerPacksByID[set.Set.ID] = cache
|
||||
return cache
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) findOriginalStickerDocument(ctx context.Context, meta *event.BridgedSticker, forceClearCache bool) (tg.InputMediaClass, error) {
|
||||
if meta == nil || !strings.HasPrefix(meta.PackURL, StickerPackURLPrefix) {
|
||||
return nil, nil
|
||||
}
|
||||
shortName := strings.TrimPrefix(meta.PackURL, StickerPackURLPrefix)
|
||||
if shortName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
idNum, err := strconv.ParseInt(meta.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
cache, err := tc.GetCachedStickerPack(ctx, shortName, nil, forceClearCache)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stickerDoc, ok := cache.docs[idNum]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &tg.InputMediaDocument{ID: stickerDoc.AsInput()}, nil
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// 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 (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/ptr"
|
||||
"go.mau.fi/zerozap"
|
||||
"go.uber.org/zap"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
LoginFlowIDPhone = "phone"
|
||||
LoginFlowIDQR = "qr"
|
||||
LoginFlowIDBotToken = "bot"
|
||||
|
||||
LoginStepIDComplete = "fi.mau.telegram.login.complete"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPassword = bridgev2.RespError{
|
||||
ErrCode: "FI.MAU.TELEGRAM.INVALID_PASSWORD",
|
||||
Err: "Invalid password",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
ErrPhoneCodeInvalid = bridgev2.RespError{
|
||||
ErrCode: "FI.MAU.TELEGRAM.PHONE_CODE_INVALID",
|
||||
Err: "Invalid phone code",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
ErrSignUpNotSupported = bridgev2.RespError{
|
||||
ErrCode: "FI.MAU.TELEGRAM.SIGN_UP_NOT_SUPPORTED",
|
||||
Err: "New account creation is not supported",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
)
|
||||
|
||||
func (tc *TelegramConnector) GetLoginFlows() []bridgev2.LoginFlow {
|
||||
return []bridgev2.LoginFlow{
|
||||
{
|
||||
Name: "Phone Number",
|
||||
Description: "Login using your Telegram phone number",
|
||||
ID: LoginFlowIDPhone,
|
||||
},
|
||||
{
|
||||
Name: "QR Code",
|
||||
Description: "Login by scanning a QR code from your phone",
|
||||
ID: LoginFlowIDQR,
|
||||
},
|
||||
{
|
||||
Name: "Bot token",
|
||||
Description: "Log in as a bot using the bot token provided by BotFather.",
|
||||
ID: LoginFlowIDBotToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TelegramConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
|
||||
bl := &baseLogin{
|
||||
user: user,
|
||||
main: tc,
|
||||
flowID: flowID,
|
||||
}
|
||||
switch flowID {
|
||||
case LoginFlowIDBotToken:
|
||||
return &BotLogin{baseLogin: bl}, nil
|
||||
case LoginFlowIDPhone:
|
||||
return &PhoneLogin{baseLogin: bl}, nil
|
||||
case LoginFlowIDQR:
|
||||
return &QRLogin{baseLogin: bl}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown flow ID %s", flowID)
|
||||
}
|
||||
}
|
||||
|
||||
type baseLogin struct {
|
||||
user *bridgev2.User
|
||||
main *TelegramConnector
|
||||
session UserLoginSession
|
||||
client *telegram.Client
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
flowID string
|
||||
}
|
||||
|
||||
func (bl *baseLogin) Cancel() {
|
||||
if bl.cancel != nil {
|
||||
bl.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (bl *baseLogin) makeClient(ctx context.Context, dispatcher *tg.UpdateDispatcher) error {
|
||||
log := zerolog.Ctx(ctx)
|
||||
zaplog := zap.New(zerozap.NewWithLevels(*log, zapLevelMap))
|
||||
if dispatcher == nil {
|
||||
dispatcher = ptr.Ptr(tg.NewUpdateDispatcher())
|
||||
}
|
||||
resolver, err := GetProxyResolver(bl.main.Config.ProxyConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bl.client = telegram.NewClient(bl.main.Config.APIID, bl.main.Config.APIHash, telegram.Options{
|
||||
Resolver: resolver,
|
||||
CustomSessionStorage: &bl.session,
|
||||
Logger: zaplog,
|
||||
Device: bl.main.deviceConfig(),
|
||||
UpdateHandler: updates.New(updates.Config{
|
||||
Handler: dispatcher,
|
||||
Logger: zaplog.Named("login_update_manager"),
|
||||
}),
|
||||
NoUpdates: true,
|
||||
})
|
||||
|
||||
bl.ctx, bl.cancel = context.WithTimeoutCause(log.WithContext(bl.main.Bridge.BackgroundCtx), LoginTimeout, ErrLoginTimeout)
|
||||
connectResult := NewFuture[error]()
|
||||
go func() {
|
||||
err := bl.client.Run(bl.ctx, func(ctx context.Context) error {
|
||||
connectResult.Set(nil)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
})
|
||||
connectResult.Set(err)
|
||||
if err != nil && !errors.Is(err, bl.ctx.Err()) {
|
||||
log.Err(err).Msg("Login client exited with error")
|
||||
}
|
||||
}()
|
||||
|
||||
log.Debug().Msg("Waiting for client to connect")
|
||||
connErr, ctxErr := connectResult.Get(ctx)
|
||||
if err := cmp.Or(connErr, ctxErr); err != nil {
|
||||
bl.Cancel()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var passwordLoginStep = &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDPassword,
|
||||
Instructions: "You have two-factor authentication enabled.",
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{{
|
||||
Type: bridgev2.LoginInputFieldTypePassword,
|
||||
ID: LoginStepIDPassword,
|
||||
Name: "Password",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
var passwordIncorrectLoginStep = &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDPasswordIncorrect,
|
||||
Instructions: "Incorrect password, please try again. Use the official Telegram app to reset your password if you've forgotten it.",
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{{
|
||||
Type: bridgev2.LoginInputFieldTypePassword,
|
||||
ID: LoginStepIDPassword,
|
||||
Name: "Password",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
func (bl *baseLogin) submitPassword(ctx context.Context, password, loginPhone string) (*bridgev2.LoginStep, error) {
|
||||
if bl.client == nil {
|
||||
return nil, fmt.Errorf("unexpected state: client is nil when submitting password")
|
||||
} else if password == "" {
|
||||
return nil, fmt.Errorf("password not provided")
|
||||
}
|
||||
authorization, err := bl.client.Auth().Password(ctx, password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrPasswordInvalid) {
|
||||
return passwordIncorrectLoginStep, nil
|
||||
}
|
||||
bl.Cancel()
|
||||
return nil, fmt.Errorf("failed to submit password: %w", err)
|
||||
}
|
||||
return bl.finalizeLogin(ctx, authorization, &UserLoginMetadata{LoginPhone: loginPhone})
|
||||
}
|
||||
|
||||
func (bl *baseLogin) finalizeLogin(
|
||||
ctx context.Context,
|
||||
authorization *tg.AuthAuthorization,
|
||||
metadata *UserLoginMetadata,
|
||||
) (*bridgev2.LoginStep, error) {
|
||||
self, err := bl.client.Self(ctx)
|
||||
bl.Cancel()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get self: %w", err)
|
||||
}
|
||||
if metadata == nil {
|
||||
metadata = &UserLoginMetadata{}
|
||||
}
|
||||
metadata.Session = bl.session
|
||||
metadata.LoginMethod = bl.flowID
|
||||
profile, name := bl.main.userToRemoteProfile(self, nil, nil)
|
||||
userLoginID := ids.MakeUserLoginID(authorization.User.GetID())
|
||||
ul, err := bl.user.NewLogin(ctx, &database.UserLogin{
|
||||
ID: userLoginID,
|
||||
Metadata: metadata,
|
||||
RemoteProfile: profile,
|
||||
RemoteName: name,
|
||||
}, &bridgev2.NewLoginParams{
|
||||
DeleteOnConflict: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save new login: %w", err)
|
||||
}
|
||||
client := ul.Client.(*TelegramClient)
|
||||
client.isNewLogin = true
|
||||
client.Connect(ul.Log.WithContext(bl.main.Bridge.BackgroundCtx))
|
||||
|
||||
bgCtx := ul.Log.WithContext(bl.main.Bridge.BackgroundCtx)
|
||||
go func() {
|
||||
if metadata.IsBot {
|
||||
return
|
||||
}
|
||||
log := ul.Log.With().Str("action", "post-login sync").Logger()
|
||||
err := client.clientInitialized.Wait(bgCtx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to wait for client init to sync chats after login")
|
||||
} else if client.clientDone.IsSet() {
|
||||
log.Warn().Msg("Client is already done after login, skipping chat sync")
|
||||
} else if err = client.syncChats(log.WithContext(client.clientCtx), 0, true, false); err != nil {
|
||||
log.Err(err).Msg("Failed to sync chats")
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if metadata.IsBot {
|
||||
return
|
||||
}
|
||||
if !bl.main.Config.Takeout.BackwardBackfill && !bl.main.Config.Takeout.ForwardBackfill && !bl.main.Config.Takeout.DialogSync {
|
||||
return
|
||||
}
|
||||
log := ul.Log.With().Str("component", "post-login takeout").Logger()
|
||||
client.takeoutLock.Lock()
|
||||
defer client.takeoutLock.Unlock()
|
||||
err := client.clientInitialized.Wait(bgCtx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to wait for client init to start takeout")
|
||||
} else if client.clientDone.IsSet() {
|
||||
log.Warn().Msg("Client is already done after login, skipping takeout")
|
||||
} else if _, err = client.getTakeoutID(bgCtx); err != nil {
|
||||
log.Err(err).Msg("Failed to get takeout")
|
||||
} else if client.stopTakeoutTimer == nil {
|
||||
client.stopTakeoutTimer = time.AfterFunc(max(time.Hour, time.Duration(client.main.Bridge.Config.Backfill.Queue.BatchDelay*2)), sync.OnceFunc(func() {
|
||||
err := client.stopTakeout(bgCtx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error stopping takeout in timer started after login")
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
client.stopTakeoutTimer.Reset(max(time.Hour, time.Duration(client.main.Bridge.Config.Backfill.Queue.BatchDelay*2)))
|
||||
}
|
||||
}()
|
||||
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeComplete,
|
||||
StepID: LoginStepIDComplete,
|
||||
Instructions: fmt.Sprintf("Successfully logged in as %s (`%d`)", ul.RemoteName, self.ID),
|
||||
CompleteParams: &bridgev2.LoginCompleteParams{
|
||||
UserLoginID: ul.ID,
|
||||
UserLogin: ul,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
)
|
||||
|
||||
const (
|
||||
LoginStepIDBotToken = "fi.mau.telegram.login.bot_token"
|
||||
)
|
||||
|
||||
type BotLogin struct {
|
||||
*baseLogin
|
||||
}
|
||||
|
||||
func (bl *BotLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) {
|
||||
meta := override.Metadata.(*UserLoginMetadata)
|
||||
if !meta.IsBot {
|
||||
return nil, fmt.Errorf("can't re-login to a non-bot account with bot token")
|
||||
}
|
||||
return bl.Start(ctx)
|
||||
}
|
||||
|
||||
func (bl *BotLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDBotToken,
|
||||
Instructions: "Please enter the bot token you want to log in as",
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{{
|
||||
Type: bridgev2.LoginInputFieldTypeToken,
|
||||
ID: LoginStepIDBotToken,
|
||||
Name: "Bot token",
|
||||
Pattern: `^\d+:[A-Za-z0-9_-]{35}$`,
|
||||
}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (bl *BotLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
|
||||
log := zerolog.Ctx(ctx).With().Str("component", "telegram bot login").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
botToken := input[LoginStepIDBotToken]
|
||||
dialFunc, err := GetProxyDialFunc(bl.main.Config.ProxyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: dialFunc,
|
||||
},
|
||||
}
|
||||
err = logoutBotAPI(ctx, botToken, httpClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to logout from bot API: %w", err)
|
||||
}
|
||||
|
||||
err = bl.makeClient(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorization, err := bl.client.Auth().Bot(ctx, botToken)
|
||||
if err != nil {
|
||||
bl.Cancel()
|
||||
return nil, err
|
||||
}
|
||||
return bl.finalizeLogin(ctx, authorization, &UserLoginMetadata{IsBot: true})
|
||||
}
|
||||
|
||||
type botAPIResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func logoutBotAPI(ctx context.Context, token string, client *http.Client) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.telegram.org/bot"+token+"/logOut", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare request: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
var respData botAPIResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&respData)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
} else if !respData.OK && respData.Description != "Logged out" {
|
||||
return fmt.Errorf("response error %d: %s", respData.ErrorCode, respData.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ bridgev2.LoginProcessUserInput = (*BotLogin)(nil)
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/auth"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
const (
|
||||
LoginStepIDPhoneNumber = "fi.mau.telegram.login.phone_number"
|
||||
LoginStepIDCode = "fi.mau.telegram.login.code"
|
||||
LoginStepIDCodeIncorrect = "fi.mau.telegram.login.code.incorrect"
|
||||
LoginStepIDPassword = "fi.mau.telegram.login.password"
|
||||
LoginStepIDPasswordIncorrect = "fi.mau.telegram.login.password.incorrect"
|
||||
)
|
||||
|
||||
type PhoneLogin struct {
|
||||
*baseLogin
|
||||
phone string
|
||||
hash string
|
||||
codeSubmitted bool
|
||||
}
|
||||
|
||||
var (
|
||||
_ bridgev2.LoginProcessUserInput = (*PhoneLogin)(nil)
|
||||
_ bridgev2.LoginProcessWithOverride = (*PhoneLogin)(nil)
|
||||
)
|
||||
|
||||
func (pl *PhoneLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) {
|
||||
meta := override.Metadata.(*UserLoginMetadata)
|
||||
if meta.IsBot {
|
||||
return nil, fmt.Errorf("can't re-login to a bot account with phone login")
|
||||
}
|
||||
phone := cmp.Or(meta.LoginPhone, override.RemoteProfile.Phone)
|
||||
if phone != "" {
|
||||
zerolog.Ctx(ctx).Debug().Str("phone_number", phone).Msg("Using existing phone number for relogin")
|
||||
return pl.submitNumber(ctx, phone)
|
||||
}
|
||||
zerolog.Ctx(ctx).Debug().Msg("No existing phone number for relogin, re-prompting")
|
||||
return pl.Start(ctx)
|
||||
}
|
||||
|
||||
func (pl *PhoneLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDPhoneNumber,
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{{
|
||||
Type: bridgev2.LoginInputFieldTypePhoneNumber,
|
||||
ID: LoginStepIDPhoneNumber,
|
||||
Name: "Phone number",
|
||||
Description: "Include the country code with +",
|
||||
}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (pl *PhoneLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
|
||||
if pl.client == nil {
|
||||
return pl.submitNumber(ctx, input[LoginStepIDPhoneNumber])
|
||||
} else if pl.codeSubmitted {
|
||||
return pl.submitPassword(ctx, input[LoginStepIDPassword], pl.phone)
|
||||
} else {
|
||||
return pl.submitCode(ctx, input[LoginStepIDCode])
|
||||
}
|
||||
}
|
||||
|
||||
var phoneLoginStep = &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDCode,
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{{
|
||||
Type: bridgev2.LoginInputFieldType2FACode,
|
||||
ID: LoginStepIDCode,
|
||||
Name: "Code",
|
||||
Description: "The code was sent to the Telegram app on your phone",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
var phoneCodeIncorrectStep = &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeUserInput,
|
||||
StepID: LoginStepIDCodeIncorrect,
|
||||
Instructions: "Incorrect code",
|
||||
UserInputParams: &bridgev2.LoginUserInputParams{
|
||||
Fields: []bridgev2.LoginInputDataField{{
|
||||
Type: bridgev2.LoginInputFieldType2FACode,
|
||||
ID: LoginStepIDCode,
|
||||
Name: "Code",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
func (pl *PhoneLogin) submitNumber(ctx context.Context, phone string) (*bridgev2.LoginStep, error) {
|
||||
if phone == "" {
|
||||
return nil, fmt.Errorf("phone number is empty")
|
||||
}
|
||||
log := zerolog.Ctx(ctx).With().Str("component", "phone login").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
pl.phone = phone
|
||||
err := pl.makeClient(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sentCode, err := pl.client.Auth().SendCode(ctx, pl.phone, auth.SendCodeOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch s := sentCode.(type) {
|
||||
case *tg.AuthSentCode:
|
||||
pl.hash = s.PhoneCodeHash
|
||||
return phoneLoginStep, nil
|
||||
case *tg.AuthSentCodeSuccess:
|
||||
switch authorization := s.Authorization.(type) {
|
||||
case *tg.AuthAuthorization:
|
||||
return pl.finalizeLogin(ctx, authorization, &UserLoginMetadata{LoginPhone: pl.phone})
|
||||
case *tg.AuthAuthorizationSignUpRequired:
|
||||
return nil, ErrSignUpNotSupported
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected authorization type: %T", sentCode)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected sent code type: %T", sentCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (pl *PhoneLogin) submitCode(ctx context.Context, code string) (*bridgev2.LoginStep, error) {
|
||||
if pl.client == nil {
|
||||
return nil, fmt.Errorf("unexpected state: client is nil when submitting phone code")
|
||||
}
|
||||
authorization, err := pl.client.Auth().SignIn(ctx, pl.phone, code, pl.hash)
|
||||
if errors.Is(err, auth.ErrPasswordAuthNeeded) {
|
||||
pl.codeSubmitted = true
|
||||
return passwordLoginStep, nil
|
||||
} else if errors.Is(err, auth.ErrPhoneCodeInvalid) {
|
||||
return phoneCodeIncorrectStep, nil
|
||||
} else if errors.Is(err, &auth.SignUpRequired{}) {
|
||||
return nil, ErrSignUpNotSupported
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to submit code: %w", err)
|
||||
}
|
||||
return pl.finalizeLogin(ctx, authorization, &UserLoginMetadata{LoginPhone: pl.phone})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/auth/qrlogin"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
|
||||
)
|
||||
|
||||
type qrAuthResult struct {
|
||||
Authorization *tg.AuthAuthorization
|
||||
Error error
|
||||
}
|
||||
|
||||
type QRLogin struct {
|
||||
*baseLogin
|
||||
auth chan qrAuthResult
|
||||
qrToken chan qrlogin.Token
|
||||
}
|
||||
|
||||
const LoginStepIDShowQR = "fi.mau.telegram.login.show_qr"
|
||||
|
||||
var _ bridgev2.LoginProcessDisplayAndWait = (*QRLogin)(nil) // For showing QR code
|
||||
var _ bridgev2.LoginProcessUserInput = (*QRLogin)(nil) // For asking for password
|
||||
|
||||
func waitContextDone(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
const LoginTimeout = 10 * time.Minute
|
||||
|
||||
var ErrLoginTimeout = errors.New("login process timed out")
|
||||
|
||||
func (ql *QRLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) {
|
||||
meta := override.Metadata.(*UserLoginMetadata)
|
||||
if meta.IsBot {
|
||||
return nil, fmt.Errorf("can't re-login to a bot account with QR login")
|
||||
}
|
||||
return ql.Start(ctx)
|
||||
}
|
||||
|
||||
func (ql *QRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
|
||||
log := zerolog.Ctx(ctx).With().Str("component", "qr login").Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
|
||||
loggedIn := make(chan struct{})
|
||||
dispatcher := tg.NewUpdateDispatcher()
|
||||
dispatcher.OnLoginToken(func(ctx context.Context, e tg.Entities, update *tg.UpdateLoginToken) error {
|
||||
log.Debug().Msg("Received updateLoginToken")
|
||||
close(loggedIn)
|
||||
return nil
|
||||
})
|
||||
err := ql.makeClient(ctx, &dispatcher)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ql.qrToken = make(chan qrlogin.Token)
|
||||
ql.auth = make(chan qrAuthResult)
|
||||
go func() {
|
||||
auth, err := ql.client.QR().Auth(ql.ctx, loggedIn, func(ctx context.Context, token qrlogin.Token) error {
|
||||
ql.qrToken <- token
|
||||
return nil
|
||||
})
|
||||
|
||||
ql.auth <- qrAuthResult{auth, err}
|
||||
}()
|
||||
|
||||
// Wait for the first QR token and show it to the user.:
|
||||
select {
|
||||
case token := <-ql.qrToken:
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeDisplayAndWait,
|
||||
StepID: LoginStepIDShowQR,
|
||||
Instructions: "Scan the QR code on your phone to log in",
|
||||
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
|
||||
Type: bridgev2.LoginDisplayTypeQR,
|
||||
Data: token.URL(),
|
||||
},
|
||||
}, nil
|
||||
case <-ctx.Done():
|
||||
ql.Cancel()
|
||||
return nil, ctx.Err()
|
||||
case <-ql.ctx.Done():
|
||||
return nil, ql.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (ql *QRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
|
||||
if ql.qrToken == nil {
|
||||
panic("qr token channel is nil")
|
||||
}
|
||||
|
||||
select {
|
||||
case token := <-ql.qrToken:
|
||||
// There's a new token, show it to the user.
|
||||
return &bridgev2.LoginStep{
|
||||
Type: bridgev2.LoginStepTypeDisplayAndWait,
|
||||
StepID: LoginStepIDShowQR,
|
||||
Instructions: "Scan the QR code on your phone to log in",
|
||||
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
|
||||
Type: bridgev2.LoginDisplayTypeQR,
|
||||
Data: token.URL(),
|
||||
},
|
||||
}, nil
|
||||
case authResult := <-ql.auth:
|
||||
if tgerr.Is(authResult.Error, "SESSION_PASSWORD_NEEDED") {
|
||||
return passwordLoginStep, nil
|
||||
} else if authResult.Error != nil {
|
||||
ql.Cancel()
|
||||
return nil, fmt.Errorf("failed to authenticate: %w", authResult.Error)
|
||||
}
|
||||
|
||||
return ql.finalizeLogin(ctx, authResult.Authorization, nil)
|
||||
case <-ctx.Done():
|
||||
ql.Cancel()
|
||||
return nil, ctx.Err()
|
||||
case <-ql.ctx.Done():
|
||||
return nil, ql.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (ql *QRLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
|
||||
return ql.submitPassword(ctx, input[LoginStepIDPassword], "")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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 matrixfmt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/event"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
func toTelegramEntity(br telegramfmt.BodyRange) tg.MessageEntityClass {
|
||||
switch val := br.Value.(type) {
|
||||
case telegramfmt.Mention:
|
||||
if val.Username != "" {
|
||||
return &tg.MessageEntityMention{Offset: br.Start, Length: br.Length}
|
||||
} else {
|
||||
peerType, userID, _ := ids.ParseUserID(val.UserID)
|
||||
if peerType != ids.PeerTypeUser {
|
||||
panic(fmt.Errorf("unexpected peer type in mention %T", peerType))
|
||||
}
|
||||
return &tg.InputMessageEntityMentionName{
|
||||
Offset: br.Start,
|
||||
Length: br.Length,
|
||||
UserID: &tg.InputUser{UserID: userID, AccessHash: val.AccessHash},
|
||||
}
|
||||
}
|
||||
case telegramfmt.Style:
|
||||
switch val.Type {
|
||||
case telegramfmt.StyleBold:
|
||||
return &tg.MessageEntityBold{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleItalic:
|
||||
return &tg.MessageEntityItalic{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleUnderline:
|
||||
return &tg.MessageEntityUnderline{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleStrikethrough:
|
||||
return &tg.MessageEntityStrike{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleBlockquote:
|
||||
return &tg.MessageEntityBlockquote{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleCode:
|
||||
return &tg.MessageEntityCode{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StylePre:
|
||||
return &tg.MessageEntityPre{Offset: br.Start, Length: br.Length, Language: val.Language}
|
||||
case telegramfmt.StyleEmail:
|
||||
return &tg.MessageEntityEmail{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleTextURL:
|
||||
return &tg.MessageEntityTextURL{Offset: br.Start, Length: br.Length, URL: val.URL}
|
||||
case telegramfmt.StyleURL:
|
||||
return &tg.MessageEntityURL{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleBotCommand:
|
||||
return &tg.MessageEntityBotCommand{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleHashtag:
|
||||
return &tg.MessageEntityHashtag{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleCashtag:
|
||||
return &tg.MessageEntityCashtag{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StylePhone:
|
||||
return &tg.MessageEntityPhone{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleSpoiler:
|
||||
return &tg.MessageEntitySpoiler{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleBankCard:
|
||||
return &tg.MessageEntityBankCard{Offset: br.Start, Length: br.Length}
|
||||
case telegramfmt.StyleCustomEmoji:
|
||||
return &tg.MessageEntityCustomEmoji{Offset: br.Start, Length: br.Length, DocumentID: val.EmojiInfo.DocumentID}
|
||||
default:
|
||||
panic("unsupported style type")
|
||||
}
|
||||
default:
|
||||
panic("unknown body range value")
|
||||
}
|
||||
}
|
||||
|
||||
func Parse(ctx context.Context, parser *HTMLParser, content *event.MessageEventContent, portal *bridgev2.Portal) (string, []tg.MessageEntityClass) {
|
||||
if content.MsgType.IsMedia() && (content.FileName == "" || content.FileName == content.Body) {
|
||||
// The body is the filename.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if content.Format != event.FormatHTML {
|
||||
return content.Body, nil
|
||||
}
|
||||
parseCtx := NewContext(ctx, portal)
|
||||
parseCtx.AllowedMentions = content.Mentions
|
||||
parsed := parser.Parse(content.FormattedBody, parseCtx)
|
||||
if parsed == nil {
|
||||
return "", nil
|
||||
}
|
||||
var entities []tg.MessageEntityClass
|
||||
if len(parsed.Entities) > 0 {
|
||||
entities = make([]tg.MessageEntityClass, len(parsed.Entities))
|
||||
for i, ent := range parsed.Entities {
|
||||
entities[i] = toTelegramEntity(ent)
|
||||
}
|
||||
}
|
||||
return parsed.String.String(), entities
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
// 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 matrixfmt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"golang.org/x/net/html"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/emojis"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/store"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
|
||||
)
|
||||
|
||||
type EntityString struct {
|
||||
String telegramfmt.UTF16String
|
||||
Entities telegramfmt.BodyRangeList
|
||||
}
|
||||
|
||||
var DebugLog = func(format string, args ...any) {}
|
||||
|
||||
func NewEntityString(val string) *EntityString {
|
||||
DebugLog("NEW %q\n", val)
|
||||
return &EntityString{
|
||||
String: telegramfmt.NewUTF16String(val),
|
||||
}
|
||||
}
|
||||
|
||||
func (es *EntityString) Split(at uint16) []*EntityString {
|
||||
if at > 0x7F {
|
||||
panic("cannot split at non-ASCII character")
|
||||
}
|
||||
if es == nil {
|
||||
return []*EntityString{}
|
||||
}
|
||||
DebugLog("SPLIT %q %q %+v\n", es.String, rune(at), es.Entities)
|
||||
var output []*EntityString
|
||||
prevSplit := 0
|
||||
doSplit := func(i int) *EntityString {
|
||||
newES := &EntityString{
|
||||
String: es.String[prevSplit:i],
|
||||
}
|
||||
for _, entity := range es.Entities {
|
||||
if (entity.End() <= i || entity.End() > prevSplit) && (entity.Start >= prevSplit || entity.Start < i) {
|
||||
entity = *entity.TruncateStart(prevSplit).TruncateEnd(i).Offset(-prevSplit)
|
||||
if entity.Length > 0 {
|
||||
newES.Entities = append(newES.Entities, entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
return newES
|
||||
}
|
||||
for i, chr := range es.String {
|
||||
if chr != at {
|
||||
continue
|
||||
}
|
||||
newES := doSplit(i)
|
||||
output = append(output, newES)
|
||||
DebugLog(" -> %q %+v\n", newES.String, newES.Entities)
|
||||
prevSplit = i + 1
|
||||
}
|
||||
if prevSplit == 0 {
|
||||
DebugLog(" -> NOOP\n")
|
||||
return []*EntityString{es}
|
||||
}
|
||||
if prevSplit != len(es.String) {
|
||||
newES := doSplit(len(es.String))
|
||||
output = append(output, newES)
|
||||
DebugLog(" -> %q %+v\n", newES.String, newES.Entities)
|
||||
}
|
||||
DebugLog("SPLITEND\n")
|
||||
return output
|
||||
}
|
||||
|
||||
func (es *EntityString) TrimSpace() *EntityString {
|
||||
if es == nil {
|
||||
return nil
|
||||
}
|
||||
DebugLog("TRIMSPACE %q %+v\n", es.String, es.Entities)
|
||||
var cutEnd, cutStart int
|
||||
for cutStart = 0; cutStart < len(es.String); cutStart++ {
|
||||
switch es.String[cutStart] {
|
||||
case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0:
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if cutStart == len(es.String) {
|
||||
DebugLog(" -> ALLSPACE\n")
|
||||
return &EntityString{}
|
||||
}
|
||||
for cutEnd = len(es.String) - 1; cutEnd >= 0; cutEnd-- {
|
||||
switch es.String[cutEnd] {
|
||||
case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0:
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
cutEnd++
|
||||
if cutStart == 0 && cutEnd == len(es.String) {
|
||||
DebugLog(" -> NOOP\n")
|
||||
return es
|
||||
}
|
||||
newEntities := es.Entities[:0]
|
||||
for _, ent := range es.Entities {
|
||||
ent = *ent.Offset(-cutStart).TruncateEnd(cutEnd)
|
||||
if ent.Length > 0 {
|
||||
newEntities = append(newEntities, ent)
|
||||
}
|
||||
}
|
||||
es.String = es.String[cutStart:cutEnd]
|
||||
es.Entities = newEntities
|
||||
DebugLog(" -> %q %+v\n", es.String, es.Entities)
|
||||
return es
|
||||
}
|
||||
|
||||
func JoinEntityString(with string, strings ...*EntityString) *EntityString {
|
||||
withUTF16 := telegramfmt.NewUTF16String(with)
|
||||
totalLen := 0
|
||||
totalEntities := 0
|
||||
for _, s := range strings {
|
||||
totalLen += len(s.String)
|
||||
totalEntities += len(s.Entities)
|
||||
}
|
||||
str := make(telegramfmt.UTF16String, 0, totalLen+len(strings)*len(withUTF16))
|
||||
entities := make(telegramfmt.BodyRangeList, 0, totalEntities)
|
||||
DebugLog("JOIN %q %d\n", with, len(strings))
|
||||
for _, s := range strings {
|
||||
if s == nil || len(s.String) == 0 {
|
||||
continue
|
||||
}
|
||||
DebugLog(" + %q %+v\n", s.String, s.Entities)
|
||||
for _, entity := range s.Entities {
|
||||
entity.Start += len(str)
|
||||
entities = append(entities, entity)
|
||||
}
|
||||
str = append(str, s.String...)
|
||||
str = append(str, withUTF16...)
|
||||
}
|
||||
DebugLog(" -> %q %+v\n", str, entities)
|
||||
return &EntityString{
|
||||
String: str,
|
||||
Entities: entities,
|
||||
}
|
||||
}
|
||||
|
||||
func (es *EntityString) Format(value telegramfmt.BodyRangeValue) *EntityString {
|
||||
if es == nil {
|
||||
return nil
|
||||
}
|
||||
newEntity := telegramfmt.BodyRange{
|
||||
Start: 0,
|
||||
Length: len(es.String),
|
||||
Value: value,
|
||||
}
|
||||
es.Entities = append(telegramfmt.BodyRangeList{newEntity}, es.Entities...)
|
||||
DebugLog("FORMAT %v %q %+v\n", value, es.String, es.Entities)
|
||||
return es
|
||||
}
|
||||
|
||||
func (es *EntityString) Append(other *EntityString) *EntityString {
|
||||
if es == nil {
|
||||
return other
|
||||
} else if other == nil {
|
||||
return es
|
||||
}
|
||||
DebugLog("APPEND %q %+v\n + %q %+v\n", es.String, es.Entities, other.String, other.Entities)
|
||||
for _, entity := range other.Entities {
|
||||
entity.Start += len(es.String)
|
||||
es.Entities = append(es.Entities, entity)
|
||||
}
|
||||
es.String = append(es.String, other.String...)
|
||||
DebugLog(" -> %q %+v\n", es.String, es.Entities)
|
||||
return es
|
||||
}
|
||||
|
||||
func (es *EntityString) AppendString(other string) *EntityString {
|
||||
if es == nil {
|
||||
return NewEntityString(other)
|
||||
} else if len(other) == 0 {
|
||||
return es
|
||||
}
|
||||
DebugLog("APPENDSTRING %q %+v\n + %q\n", es.String, es.Entities, other)
|
||||
es.String = append(es.String, telegramfmt.NewUTF16String(other)...)
|
||||
DebugLog(" -> %q %+v\n", es.String, es.Entities)
|
||||
return es
|
||||
}
|
||||
|
||||
type TagStack []string
|
||||
|
||||
func (ts TagStack) Index(tag string) int {
|
||||
for i := len(ts) - 1; i >= 0; i-- {
|
||||
if ts[i] == tag {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (ts TagStack) Has(tag string) bool {
|
||||
return ts.Index(tag) >= 0
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
Ctx context.Context
|
||||
Portal *bridgev2.Portal
|
||||
AllowedMentions *event.Mentions
|
||||
TagStack TagStack
|
||||
PreserveWhitespace bool
|
||||
ListDepth int
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, portal *bridgev2.Portal) Context {
|
||||
return Context{
|
||||
Ctx: ctx,
|
||||
TagStack: make(TagStack, 0, 4),
|
||||
Portal: portal,
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx Context) WithTag(tag string) Context {
|
||||
ctx.TagStack = append(ctx.TagStack, tag)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx Context) WithWhitespace() Context {
|
||||
ctx.PreserveWhitespace = true
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx Context) WithIncrementedListDepth() Context {
|
||||
ctx.ListDepth++
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HTMLParser is a somewhat customizable Matrix HTML parser.
|
||||
type HTMLParser struct {
|
||||
Bridge *bridgev2.Bridge
|
||||
Store *store.Container
|
||||
ScopedStore *store.ScopedStore
|
||||
}
|
||||
|
||||
// TaggedString is a string that also contains a HTML tag.
|
||||
type TaggedString struct {
|
||||
*EntityString
|
||||
tag string
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) {
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Key == attribute {
|
||||
return attr.Val, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) getAttribute(node *html.Node, attribute string) string {
|
||||
val, _ := parser.maybeGetAttribute(node, attribute)
|
||||
return val
|
||||
}
|
||||
|
||||
// Digits counts the number of digits (and the sign, if negative) in an integer.
|
||||
func Digits(num int) int {
|
||||
if num == 0 {
|
||||
return 1
|
||||
} else if num < 0 {
|
||||
return Digits(-num) + 1
|
||||
}
|
||||
return int(math.Floor(math.Log10(float64(num))) + 1)
|
||||
}
|
||||
|
||||
var listBullets = []string{"●", "○", "■", "‣"}
|
||||
|
||||
func (parser *HTMLParser) listToString(node *html.Node, ctx Context) *EntityString {
|
||||
ordered := node.Data == "ol"
|
||||
if !ordered {
|
||||
ctx = ctx.WithIncrementedListDepth()
|
||||
}
|
||||
taggedChildren := parser.nodeToTaggedStrings(node.FirstChild, ctx)
|
||||
counter := 1
|
||||
indentLength := 0
|
||||
if ordered {
|
||||
start := parser.getAttribute(node, "start")
|
||||
if len(start) > 0 {
|
||||
counter, _ = strconv.Atoi(start)
|
||||
}
|
||||
|
||||
longestIndex := (counter - 1) + len(taggedChildren)
|
||||
indentLength = Digits(longestIndex)
|
||||
}
|
||||
indent := strings.Repeat(" ", indentLength+2)
|
||||
var children []*EntityString
|
||||
for _, child := range taggedChildren {
|
||||
if child.tag != "li" {
|
||||
continue
|
||||
}
|
||||
var prefix string
|
||||
if ordered {
|
||||
indexPadding := indentLength - Digits(counter)
|
||||
if indexPadding < 0 {
|
||||
// This will happen on negative start indexes where longestIndex is usually wrong, otherwise shouldn't happen
|
||||
indexPadding = 0
|
||||
}
|
||||
prefix = fmt.Sprintf("%d. %s", counter, strings.Repeat(" ", indexPadding))
|
||||
} else {
|
||||
prefix = fmt.Sprintf("%s ", listBullets[(ctx.ListDepth-1)%len(listBullets)])
|
||||
}
|
||||
es := NewEntityString(prefix).Append(child.EntityString)
|
||||
counter++
|
||||
parts := es.Split('\n')
|
||||
for i, part := range parts[1:] {
|
||||
parts[i+1] = NewEntityString(indent).Append(part)
|
||||
}
|
||||
children = append(children, parts...)
|
||||
}
|
||||
return JoinEntityString("\n", children...)
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) basicFormatToString(node *html.Node, ctx Context) *EntityString {
|
||||
str := parser.nodeToTagAwareString(node.FirstChild, ctx)
|
||||
switch node.Data {
|
||||
case "b", "strong":
|
||||
return str.Format(telegramfmt.Style{Type: telegramfmt.StyleBold})
|
||||
case "i", "em":
|
||||
return str.Format(telegramfmt.Style{Type: telegramfmt.StyleItalic})
|
||||
case "s", "del", "strike":
|
||||
return str.Format(telegramfmt.Style{Type: telegramfmt.StyleStrikethrough})
|
||||
case "u", "ins":
|
||||
return str.Format(telegramfmt.Style{Type: telegramfmt.StyleUnderline})
|
||||
case "tt", "code":
|
||||
return str.Format(telegramfmt.Style{Type: telegramfmt.StyleCode})
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) spanToString(node *html.Node, ctx Context) *EntityString {
|
||||
str := parser.nodeToTagAwareString(node.FirstChild, ctx)
|
||||
if node.Data == "span" {
|
||||
_, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler")
|
||||
if isSpoiler {
|
||||
str = str.Format(telegramfmt.Style{Type: telegramfmt.StyleSpoiler})
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) headerToString(node *html.Node, ctx Context) *EntityString {
|
||||
length := int(node.Data[1] - '0')
|
||||
prefix := strings.Repeat("#", length) + " "
|
||||
return NewEntityString(prefix).Append(parser.nodeToString(node.FirstChild, ctx)).Format(telegramfmt.Style{Type: telegramfmt.StyleBold})
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) getGhostDetails(ctx context.Context, portal *bridgev2.Portal, ui id.UserID) (networkid.UserID, string, int64, bool) {
|
||||
userID, ok := parser.Bridge.Matrix.ParseGhostMXID(ui)
|
||||
if !ok {
|
||||
user, err := parser.Bridge.GetExistingUserByMXID(ctx, ui)
|
||||
if err != nil || user == nil {
|
||||
return "", "", 0, false
|
||||
} else if login, _, _ := portal.FindPreferredLogin(ctx, user, false); login != nil {
|
||||
userID = ids.UserLoginIDToUserID(login.ID)
|
||||
} else {
|
||||
return "", "", 0, false
|
||||
}
|
||||
}
|
||||
if peerType, telegramUserID, err := ids.ParseUserID(userID); err != nil {
|
||||
return "", "", 0, false
|
||||
} else if accessHash, err := parser.ScopedStore.GetAccessHash(ctx, peerType, telegramUserID); err != nil || accessHash == 0 {
|
||||
return "", "", 0, false
|
||||
} else if username, err := parser.Store.Username.Get(ctx, peerType, telegramUserID); err != nil {
|
||||
return "", "", 0, false
|
||||
} else {
|
||||
return userID, username, accessHash, true
|
||||
}
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) linkToString(node *html.Node, ctx Context) *EntityString {
|
||||
str := parser.nodeToTagAwareString(node.FirstChild, ctx)
|
||||
href := parser.getAttribute(node, "href")
|
||||
if len(href) == 0 {
|
||||
return str
|
||||
}
|
||||
linkText := str.String.String()
|
||||
linkTextEnt := NewEntityString(linkText)
|
||||
isRawLink := linkText == href
|
||||
|
||||
parsedMatrix, err := id.ParseMatrixURIOrMatrixToURL(href)
|
||||
if err == nil && parsedMatrix != nil && parsedMatrix.Sigil1 == '@' {
|
||||
mxid := parsedMatrix.UserID()
|
||||
if ctx.AllowedMentions != nil && !slices.Contains(ctx.AllowedMentions.UserIDs, mxid) {
|
||||
// Mention not allowed, use name as-is
|
||||
return str
|
||||
}
|
||||
userID, username, accessHash, ok := parser.getGhostDetails(ctx.Ctx, ctx.Portal, mxid)
|
||||
if !ok {
|
||||
return str
|
||||
} else if username == "" {
|
||||
return linkTextEnt.Format(telegramfmt.Mention{UserID: userID, AccessHash: accessHash})
|
||||
} else {
|
||||
return NewEntityString("@" + username).Format(telegramfmt.Mention{UserID: userID, Username: username})
|
||||
}
|
||||
}
|
||||
if parsedMatrix != nil && parsedMatrix.Sigil1 == '!' && parsedMatrix.Sigil2 == '$' {
|
||||
msg, err := parser.Bridge.DB.Message.GetPartByMXID(ctx.Ctx, parsedMatrix.EventID())
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx.Ctx).Err(err).Msg("Failed to get message for event ID in link")
|
||||
} else if msg != nil {
|
||||
_, chatID, topicID, _ := ids.ParsePortalID(msg.Room.ID)
|
||||
_, msgID, _ := ids.ParseMessageID(msg.ID)
|
||||
if msgID != 0 && chatID != 0 {
|
||||
href = fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID)
|
||||
if topicID > 0 {
|
||||
href = fmt.Sprintf("https://t.me/c/%d/%d/%d", chatID, topicID, msgID)
|
||||
}
|
||||
if isRawLink {
|
||||
linkTextEnt = NewEntityString(href)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isRawLink {
|
||||
return linkTextEnt.Format(telegramfmt.Style{Type: telegramfmt.StyleURL, URL: href})
|
||||
} else {
|
||||
return linkTextEnt.Format(telegramfmt.Style{Type: telegramfmt.StyleTextURL, URL: href})
|
||||
}
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) imgToString(node *html.Node, ctx Context) *EntityString {
|
||||
src := parser.getAttribute(node, "src")
|
||||
alt := parser.getAttribute(node, "alt")
|
||||
_, isEmoji := parser.maybeGetAttribute(node, "data-mx-emoticon")
|
||||
if !isEmoji {
|
||||
return NewEntityString(alt)
|
||||
}
|
||||
if file, _ := parser.Store.TelegramFile.GetByMXC(ctx.Ctx, id.ContentURIString(src)); file != nil {
|
||||
if documentID, err := strconv.ParseInt(string(file.LocationID), 10, 64); err == nil {
|
||||
// Hardcode to a sparkle emoji because telegram requires the custom emoji fallback to be an emoji,
|
||||
// but we don't know the actual emoji that should be used.
|
||||
return NewEntityString("\u2728\ufe0f").Format(telegramfmt.Style{Type: telegramfmt.StyleCustomEmoji, EmojiInfo: emojis.EmojiInfo{DocumentID: documentID}})
|
||||
}
|
||||
}
|
||||
return NewEntityString(alt)
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) tagToString(node *html.Node, ctx Context) *EntityString {
|
||||
ctx = ctx.WithTag(node.Data)
|
||||
switch node.Data {
|
||||
case "blockquote":
|
||||
if _, isPartialReply := parser.maybeGetAttribute(node, "data-telegram-partial-reply"); isPartialReply {
|
||||
return NewEntityString("")
|
||||
}
|
||||
return parser.
|
||||
nodeToTagAwareString(node.FirstChild, ctx).
|
||||
Format(telegramfmt.Style{Type: telegramfmt.StyleBlockquote})
|
||||
case "ol", "ul":
|
||||
return parser.listToString(node, ctx)
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
return parser.headerToString(node, ctx)
|
||||
case "br":
|
||||
return NewEntityString("\n")
|
||||
case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "tt", "code":
|
||||
return parser.basicFormatToString(node, ctx)
|
||||
case "span", "font":
|
||||
return parser.spanToString(node, ctx)
|
||||
case "a":
|
||||
return parser.linkToString(node, ctx)
|
||||
case "p":
|
||||
return parser.nodeToTagAwareString(node.FirstChild, ctx)
|
||||
case "img":
|
||||
return parser.imgToString(node, ctx)
|
||||
case "hr":
|
||||
return NewEntityString("---")
|
||||
case "pre":
|
||||
var preStr *EntityString
|
||||
var language string
|
||||
if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" {
|
||||
class := parser.getAttribute(node.FirstChild, "class")
|
||||
if strings.HasPrefix(class, "language-") {
|
||||
language = class[len("language-"):]
|
||||
}
|
||||
preStr = parser.nodeToString(node.FirstChild.FirstChild, ctx.WithWhitespace())
|
||||
} else {
|
||||
preStr = parser.nodeToString(node.FirstChild, ctx.WithWhitespace())
|
||||
}
|
||||
|
||||
preStr.Entities = nil
|
||||
return preStr.TrimSpace().Format(telegramfmt.Style{Type: telegramfmt.StylePre, Language: language})
|
||||
default:
|
||||
return parser.nodeToTagAwareString(node.FirstChild, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) singleNodeToString(node *html.Node, ctx Context) TaggedString {
|
||||
switch node.Type {
|
||||
case html.TextNode:
|
||||
if !ctx.PreserveWhitespace {
|
||||
node.Data = strings.ReplaceAll(node.Data, "\n", "")
|
||||
}
|
||||
return TaggedString{NewEntityString(node.Data), "text"}
|
||||
case html.ElementNode:
|
||||
return TaggedString{parser.tagToString(node, ctx), node.Data}
|
||||
case html.DocumentNode:
|
||||
return TaggedString{parser.nodeToTagAwareString(node.FirstChild, ctx), "html"}
|
||||
default:
|
||||
return TaggedString{&EntityString{}, "unknown"}
|
||||
}
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) nodeToTaggedStrings(node *html.Node, ctx Context) (strs []TaggedString) {
|
||||
for ; node != nil; node = node.NextSibling {
|
||||
strs = append(strs, parser.singleNodeToString(node, ctx))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var BlockTags = []string{"p", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "pre", "blockquote", "div", "hr", "table"}
|
||||
|
||||
func (parser *HTMLParser) isBlockTag(tag string) bool {
|
||||
for _, blockTag := range BlockTags {
|
||||
if tag == blockTag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) nodeToTagAwareString(node *html.Node, ctx Context) *EntityString {
|
||||
strs := parser.nodeToTaggedStrings(node, ctx)
|
||||
var output *EntityString
|
||||
for _, str := range strs {
|
||||
tstr := str.EntityString
|
||||
if parser.isBlockTag(str.tag) {
|
||||
tstr = NewEntityString("\n").Append(tstr).AppendString("\n")
|
||||
}
|
||||
if output == nil {
|
||||
output = tstr
|
||||
} else {
|
||||
output = output.Append(tstr)
|
||||
}
|
||||
}
|
||||
return output.TrimSpace()
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) nodeToStrings(node *html.Node, ctx Context) (strs []*EntityString) {
|
||||
for ; node != nil; node = node.NextSibling {
|
||||
strs = append(strs, parser.singleNodeToString(node, ctx).EntityString)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (parser *HTMLParser) nodeToString(node *html.Node, ctx Context) *EntityString {
|
||||
return JoinEntityString("", parser.nodeToStrings(node, ctx)...)
|
||||
}
|
||||
|
||||
// Parse converts Matrix HTML into text using the settings in this parser.
|
||||
func (parser *HTMLParser) Parse(htmlData string, ctx Context) *EntityString {
|
||||
node, _ := html.Parse(strings.NewReader(htmlData))
|
||||
return parser.nodeToTagAwareString(node, ctx)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// 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 media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/klauspost/compress/gzip"
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/ffmpeg"
|
||||
"go.mau.fi/util/lottie"
|
||||
)
|
||||
|
||||
type AnimatedStickerConfig struct {
|
||||
Target string `yaml:"target"`
|
||||
ConvertFromWebm bool `yaml:"convert_from_webm"`
|
||||
Args struct {
|
||||
Width int `yaml:"width"`
|
||||
Height int `yaml:"height"`
|
||||
FPS int `yaml:"fps"`
|
||||
} `yaml:"args"`
|
||||
}
|
||||
|
||||
type ConvertedSticker struct {
|
||||
Success bool
|
||||
NewPath string
|
||||
MIMEType string
|
||||
ThumbnailData []byte
|
||||
ThumbnailMIMEType string
|
||||
Width int
|
||||
Height int
|
||||
Size int
|
||||
}
|
||||
|
||||
func (c *AnimatedStickerConfig) convertWebm(ctx context.Context, src *os.File) *ConvertedSticker {
|
||||
if !c.ConvertFromWebm || c.Target == "webm" {
|
||||
return nil
|
||||
}
|
||||
log := zerolog.Ctx(ctx).With().Str("animated_sticker_target", c.Target).Logger()
|
||||
if !ffmpeg.Supported() {
|
||||
log.Warn().Msg("Not converting webm sticker as ffmpeg is not installed")
|
||||
return nil
|
||||
}
|
||||
var newPath string
|
||||
var err error
|
||||
switch c.Target {
|
||||
case "png":
|
||||
newPath, err = ffmpeg.ConvertPath(
|
||||
ctx, src.Name(), ".png",
|
||||
[]string{"-ss", "0", "-c:v", "libvpx-vp9"},
|
||||
[]string{"-frames:v", "1"},
|
||||
false,
|
||||
)
|
||||
case "gif":
|
||||
newPath, err = ffmpeg.ConvertPath(
|
||||
ctx, src.Name(), ".gif",
|
||||
[]string{"-c:v", "libvpx-vp9"},
|
||||
[]string{"-vf", "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"},
|
||||
false,
|
||||
)
|
||||
case "webp":
|
||||
newPath, err = ffmpeg.ConvertPath(
|
||||
ctx, src.Name(), ".webp",
|
||||
[]string{"-c:v", "libvpx-vp9"},
|
||||
[]string{"-loop", "0"},
|
||||
false,
|
||||
)
|
||||
default:
|
||||
log.Error().Msg("Unknown target format for webm conversion")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to convert webm sticker")
|
||||
return nil
|
||||
}
|
||||
var outputSize int64
|
||||
stat, err := os.Stat(newPath)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to stat converted sticker")
|
||||
} else {
|
||||
outputSize = stat.Size()
|
||||
}
|
||||
|
||||
_ = src.Close()
|
||||
return &ConvertedSticker{
|
||||
Success: true,
|
||||
NewPath: newPath,
|
||||
MIMEType: "image/" + c.Target,
|
||||
Width: c.Args.Width,
|
||||
Height: c.Args.Height,
|
||||
Size: int(outputSize),
|
||||
}
|
||||
}
|
||||
|
||||
func CompressGZip(src *os.File) (replPath string, err error) {
|
||||
tempFile, err := os.CreateTemp("", "telegram-sticker-gzip-*.tgs")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
writer := gzip.NewWriter(tempFile)
|
||||
defer func() {
|
||||
_ = tempFile.Close()
|
||||
_ = writer.Close()
|
||||
if replPath == "" {
|
||||
_ = os.Remove(tempFile.Name())
|
||||
}
|
||||
}()
|
||||
_, err = io.Copy(writer, src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to compress lottie gzip: %w", err)
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to close gzip writer: %w", err)
|
||||
}
|
||||
return tempFile.Name(), nil
|
||||
}
|
||||
|
||||
func extractGZip(src *os.File) (*ConvertedSticker, error) {
|
||||
reader, err := gzip.NewReader(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = reader.Close()
|
||||
}()
|
||||
replFile, err := os.OpenFile(src.Name()+".json", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = replFile.Close()
|
||||
}()
|
||||
n, err := io.Copy(replFile, reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract lottie gzip: %w", err)
|
||||
}
|
||||
return &ConvertedSticker{
|
||||
Success: true,
|
||||
NewPath: replFile.Name(),
|
||||
MIMEType: "video/lottie+json",
|
||||
Size: int(n),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AnimatedStickerConfig) convert(ctx context.Context, src *os.File) *ConvertedSticker {
|
||||
log := zerolog.Ctx(ctx).With().Str("animated_sticker_target", c.Target).Logger()
|
||||
|
||||
if c.Target == "disable" {
|
||||
converted, err := extractGZip(src)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to extract lottie sticker")
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
if !lottie.Supported() {
|
||||
log.Warn().Msg("Not converting lottie sticker as lottieconverter is not installed")
|
||||
return nil
|
||||
} else if (c.Target == "webp" || c.Target == "webm") && !ffmpeg.Supported() {
|
||||
log.Warn().Msg("Not converting lottie sticker as target is webp/webm, but ffmpeg is not installed")
|
||||
return nil
|
||||
}
|
||||
outputFilename := src.Name() + "." + c.Target
|
||||
|
||||
var thumbnailData []byte
|
||||
var mimeType, thumbnailMIMEType string
|
||||
|
||||
var err error
|
||||
switch c.Target {
|
||||
case "png":
|
||||
mimeType = "image/png"
|
||||
err = lottie.Convert(ctx, src, outputFilename, nil, c.Target, c.Args.Width, c.Args.Height, "1")
|
||||
case "gif":
|
||||
mimeType = "image/gif"
|
||||
err = lottie.Convert(ctx, src, outputFilename, nil, c.Target, c.Args.Width, c.Args.Height, strconv.Itoa(c.Args.FPS))
|
||||
case "webm", "webp":
|
||||
thumbnailMIMEType = "image/png"
|
||||
if c.Target == "webm" {
|
||||
mimeType = "video/webm"
|
||||
} else {
|
||||
mimeType = "image/webp"
|
||||
}
|
||||
thumbnailData, err = lottie.FFmpegConvert(ctx, src, outputFilename, c.Args.Width, c.Args.Height, c.Args.FPS)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
default:
|
||||
log.Error().Msg("Unknown target format")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(outputFilename)
|
||||
log.Err(err).Msg("Failed to convert animated sticker")
|
||||
return nil
|
||||
}
|
||||
var outputSize int64
|
||||
stat, err := os.Stat(outputFilename)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to stat converted sticker")
|
||||
} else {
|
||||
outputSize = stat.Size()
|
||||
}
|
||||
|
||||
_ = src.Close()
|
||||
return &ConvertedSticker{
|
||||
Success: true,
|
||||
NewPath: outputFilename,
|
||||
MIMEType: mimeType,
|
||||
ThumbnailData: thumbnailData,
|
||||
ThumbnailMIMEType: thumbnailMIMEType,
|
||||
Width: c.Args.Width,
|
||||
Height: c.Args.Height,
|
||||
Size: int(outputSize),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
// 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 media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
"maunium.net/go/mautrix/mediaproxy"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/store"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/downloader"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
type dimensionable interface {
|
||||
GetW() int
|
||||
GetH() int
|
||||
}
|
||||
|
||||
func getLargestPhotoSize(sizes []tg.PhotoSizeClass) (width, height, fileSize int, largest tg.PhotoSizeClass) {
|
||||
if len(sizes) == 0 {
|
||||
panic("cannot get largest size from empty list of sizes")
|
||||
}
|
||||
|
||||
// FIXME this max size seems to be confusing bytes and dimensions.
|
||||
for _, s := range sizes {
|
||||
var currentSize int
|
||||
switch size := s.(type) {
|
||||
case *tg.PhotoSize:
|
||||
currentSize = size.GetSize()
|
||||
case *tg.PhotoCachedSize:
|
||||
currentSize = max(size.W, size.H, len(size.Bytes))
|
||||
case *tg.PhotoSizeProgressive:
|
||||
currentSize = max(size.W, size.H)
|
||||
for _, sz := range size.Sizes {
|
||||
currentSize = max(currentSize, sz)
|
||||
}
|
||||
case *tg.PhotoPathSize:
|
||||
currentSize = len(size.GetBytes())
|
||||
case *tg.PhotoStrippedSize:
|
||||
currentSize = len(size.GetBytes())
|
||||
}
|
||||
|
||||
if currentSize > fileSize {
|
||||
fileSize = currentSize
|
||||
largest = s
|
||||
if d, ok := s.(dimensionable); ok {
|
||||
width = d.GetW()
|
||||
height = d.GetH()
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// getLocationID converts a Telegram [tg.Document],
|
||||
// [tg.InputDocumentFileLocation], [tg.InputPeerPhotoFileLocation],
|
||||
// [tg.InputFileLocation], or [tg.InputPhotoFileLocation] into a [LocationID]
|
||||
// for use in the telegram_file table.
|
||||
func getLocationID(loc any) (locID store.TelegramFileLocationID) {
|
||||
var id string
|
||||
switch location := loc.(type) {
|
||||
case *tg.Document:
|
||||
id = fmt.Sprintf("%d", location.ID)
|
||||
case *tg.InputDocumentFileLocation:
|
||||
id = fmt.Sprintf("%d-%s", location.ID, location.ThumbSize)
|
||||
case *tg.InputPhotoFileLocation:
|
||||
id = fmt.Sprintf("%d-%s", location.ID, location.ThumbSize)
|
||||
case *tg.InputFileLocation:
|
||||
id = fmt.Sprintf("%d-%d", location.VolumeID, location.LocalID)
|
||||
case *tg.InputPeerPhotoFileLocation:
|
||||
id = fmt.Sprintf("%d", location.PhotoID)
|
||||
default:
|
||||
panic(fmt.Errorf("unknown location type %T", location))
|
||||
}
|
||||
return store.TelegramFileLocationID(strings.TrimRight(id, "-"))
|
||||
}
|
||||
|
||||
// Transferer is a utility for downloading media from Telegram and uploading it
|
||||
// to Matrix.
|
||||
type Transferer struct {
|
||||
client downloader.Client
|
||||
|
||||
roomID id.RoomID
|
||||
filename string
|
||||
animatedStickerConfig *AnimatedStickerConfig
|
||||
|
||||
fileInfo event.FileInfo
|
||||
}
|
||||
|
||||
type ReadyTransferer struct {
|
||||
inner *Transferer
|
||||
loc tg.InputFileLocationClass
|
||||
}
|
||||
|
||||
// NewTransferer creates a new [Transferer] with the given [downloader.Client].
|
||||
// The client is used to download the media from Telegram.
|
||||
func NewTransferer(client downloader.Client) *Transferer {
|
||||
return &Transferer{client: client}
|
||||
}
|
||||
|
||||
// WithRoomID sets the room ID for the [Transferer].
|
||||
func (t *Transferer) WithRoomID(roomID id.RoomID) *Transferer {
|
||||
t.roomID = roomID
|
||||
return t
|
||||
}
|
||||
|
||||
// WithFilename sets the filename for the [Transferer].
|
||||
func (t *Transferer) WithFilename(filename string) *Transferer {
|
||||
t.filename = filename
|
||||
return t
|
||||
}
|
||||
|
||||
// WithStickerConfig sets the animated sticker config for the [Transferer].
|
||||
func (t *Transferer) WithStickerConfig(cfg AnimatedStickerConfig) *Transferer {
|
||||
t.animatedStickerConfig = &cfg
|
||||
t.adjustStickerSize()
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Transferer) WithStickerMetadata(meta *event.BridgedSticker) *Transferer {
|
||||
t.fileInfo.BridgedSticker = meta
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Transferer) WithForceWebmStickerConvert(force bool) *Transferer {
|
||||
if force {
|
||||
t.animatedStickerConfig.ConvertFromWebm = true
|
||||
if t.animatedStickerConfig.Target == "webm" {
|
||||
t.animatedStickerConfig.Target = "webp"
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func adjustStickerSize(info *event.FileInfo) {
|
||||
if info.Width <= 256 && info.Height <= 256 {
|
||||
return
|
||||
}
|
||||
if info.Width == info.Height {
|
||||
info.Width, info.Height = 256, 256
|
||||
} else if info.Width > info.Height {
|
||||
info.Height = info.Height * 256 / info.Width
|
||||
info.Width = 256
|
||||
} else {
|
||||
info.Width = info.Width * 256 / info.Height
|
||||
info.Height = 256
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transferer) adjustStickerSize() {
|
||||
if t.animatedStickerConfig != nil {
|
||||
adjustStickerSize(&t.fileInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transferer) WithMIMEType(mimeType string) *Transferer {
|
||||
t.fileInfo.MimeType = mimeType
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Transferer) WithThumbnail(uri id.ContentURIString, file *event.EncryptedFileInfo, info *event.FileInfo) *Transferer {
|
||||
t.fileInfo.ThumbnailURL = uri
|
||||
t.fileInfo.ThumbnailFile = file
|
||||
t.fileInfo.ThumbnailInfo = info
|
||||
// Thumbnails are hopefully always jpeg like photos
|
||||
if info.MimeType == "application/octet-stream" {
|
||||
info.MimeType = "image/jpeg"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Transferer) WithVideo(attr *tg.DocumentAttributeVideo) *Transferer {
|
||||
t.fileInfo.Width, t.fileInfo.Height = attr.W, attr.H
|
||||
t.fileInfo.Duration = int(attr.Duration * 1000)
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Transferer) WithAudio(attr *tg.DocumentAttributeAudio) *Transferer {
|
||||
t.fileInfo.Duration = attr.Duration * 1000
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Transferer) WithImageSize(attr *tg.DocumentAttributeImageSize) *Transferer {
|
||||
t.fileInfo.Width, t.fileInfo.Height = attr.W, attr.H
|
||||
t.adjustStickerSize()
|
||||
return t
|
||||
}
|
||||
|
||||
// WithDocument transforms a [Transferer] to a [ReadyTransferer] by setting the
|
||||
// given document as the location that will be downloaded by the
|
||||
// [ReadyTransferer].
|
||||
func (t *Transferer) WithDocument(doc tg.DocumentClass, thumbnail bool) *ReadyTransferer {
|
||||
document := doc.(*tg.Document)
|
||||
documentFileLocation := tg.InputDocumentFileLocation{
|
||||
ID: document.GetID(),
|
||||
AccessHash: document.GetAccessHash(),
|
||||
FileReference: document.GetFileReference(),
|
||||
}
|
||||
if thumbnail {
|
||||
_, _, _, largestThumbnail := getLargestPhotoSize(document.Thumbs)
|
||||
documentFileLocation.ThumbSize = largestThumbnail.GetType()
|
||||
} else {
|
||||
t.fileInfo.Size = int(document.Size)
|
||||
if t.fileInfo.MimeType == "" {
|
||||
t.fileInfo.MimeType = document.GetMimeType()
|
||||
}
|
||||
}
|
||||
return &ReadyTransferer{t, &documentFileLocation}
|
||||
}
|
||||
|
||||
func (t *Transferer) WithLivePhoto(pc tg.PhotoClass, doc tg.DocumentClass) *ReadyTransferer {
|
||||
photo := pc.(*tg.Photo)
|
||||
t.fileInfo.Width, t.fileInfo.Height, _, _ = getLargestPhotoSize(photo.GetSizes())
|
||||
return t.WithDocument(doc, false)
|
||||
}
|
||||
|
||||
// WithPhoto transforms a [Transferer] to a [ReadyTransferer] by setting the
|
||||
// given photo as the location that will be downloaded by the
|
||||
// [ReadyTransferer].
|
||||
func (t *Transferer) WithPhoto(pc tg.PhotoClass) *ReadyTransferer {
|
||||
photo := pc.(*tg.Photo)
|
||||
var largest tg.PhotoSizeClass
|
||||
t.fileInfo.Width, t.fileInfo.Height, t.fileInfo.Size, largest = getLargestPhotoSize(photo.GetSizes())
|
||||
// All photos are jpeg in Telegram
|
||||
t.fileInfo.MimeType = "image/jpeg"
|
||||
return &ReadyTransferer{
|
||||
inner: t,
|
||||
loc: &tg.InputPhotoFileLocation{
|
||||
ID: photo.GetID(),
|
||||
AccessHash: photo.GetAccessHash(),
|
||||
FileReference: photo.GetFileReference(),
|
||||
ThumbSize: largest.GetType(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserPhoto transforms a [Transferer] to a [ReadyTransferer] by setting
|
||||
// the given user's photo as the location that will be downloaded by the
|
||||
// [ReadyTransferer].
|
||||
func (t *Transferer) WithUserPhoto(ctx context.Context, store *store.ScopedStore, userID int64, photoID int64) (*ReadyTransferer, error) {
|
||||
if accessHash, err := store.GetAccessHash(ctx, ids.PeerTypeUser, userID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get user access hash for %d: %w", userID, err)
|
||||
} else {
|
||||
return t.WithPeerPhoto(&tg.InputPeerUser{UserID: userID, AccessHash: accessHash}, photoID), nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithChannelPhoto transforms a [Transferer] to a [ReadyTransferer] by setting
|
||||
// the given channel's photo as the location that will be downloaded by the
|
||||
// [ReadyTransferer].
|
||||
func (t *Transferer) WithChannelPhoto(ctx context.Context, store *store.ScopedStore, channelID int64, photoID int64) (*ReadyTransferer, error) {
|
||||
if accessHash, err := store.GetAccessHash(ctx, ids.PeerTypeChannel, channelID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get channel access hash for %d: %w", channelID, err)
|
||||
} else {
|
||||
return t.WithPeerPhoto(&tg.InputPeerChannel{ChannelID: channelID, AccessHash: accessHash}, photoID), nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithPeerPhoto transforms a [Transferer] to a [ReadyTransferer] by setting
|
||||
// the given user, chat or channel photo as the location that will be downloaded by the
|
||||
// [ReadyTransferer].
|
||||
func (t *Transferer) WithPeerPhoto(peer tg.InputPeerClass, photoID int64) *ReadyTransferer {
|
||||
return &ReadyTransferer{
|
||||
inner: t,
|
||||
loc: &tg.InputPeerPhotoFileLocation{
|
||||
Peer: peer,
|
||||
PhotoID: photoID,
|
||||
Big: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer downloads the media from Telegram and uploads it to Matrix.
|
||||
//
|
||||
// If the file is already in the database, the MXC URI will be reused. The
|
||||
// file's MXC URI will only be cached if the room ID is unset or if the room is
|
||||
// not encrypted.
|
||||
//
|
||||
// If there is a sticker config on the [Transferer], this function converts
|
||||
// animated stickers to the target format specified by the specified
|
||||
// [AnimatedStickerConfig].
|
||||
func (t *ReadyTransferer) Transfer(ctx context.Context, db *store.Container, intent bridgev2.MatrixAPI) (mxc id.ContentURIString, encryptedFileInfo *event.EncryptedFileInfo, outFileInfo *event.FileInfo, err error) {
|
||||
locationID := getLocationID(t.loc)
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Str("component", "media_transfer").
|
||||
Str("location_id", string(locationID)).
|
||||
Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
log.Debug().Msg("Transferring file from Telegram to Matrix")
|
||||
|
||||
if file, err := db.TelegramFile.GetByLocationID(ctx, locationID); err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to search for Telegram file by location ID: %w", err)
|
||||
} else if file != nil {
|
||||
t.inner.fileInfo.Size = file.Size
|
||||
t.inner.fileInfo.Width = file.Width
|
||||
t.inner.fileInfo.Height = file.Height
|
||||
t.inner.fileInfo.MimeType = file.MIMEType
|
||||
return file.MXC, nil, &t.inner.fileInfo, nil
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
reader, t.inner.fileInfo.MimeType, t.inner.fileInfo.Size, err = t.Stream(ctx)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("downloading file failed: %w", err)
|
||||
}
|
||||
|
||||
needStickerConvert := t.inner.animatedStickerConfig != nil && (t.inner.fileInfo.MimeType == "application/x-tgsticker" ||
|
||||
(t.inner.fileInfo.MimeType == "video/webm" && t.inner.animatedStickerConfig.ConvertFromWebm && t.inner.animatedStickerConfig.Target != "webm"))
|
||||
needsDimensions := strings.HasPrefix(t.inner.fileInfo.MimeType, "image/") &&
|
||||
t.inner.fileInfo.Width == 0 && t.inner.fileInfo.Height == 0
|
||||
|
||||
var thumbnailData []byte
|
||||
var thumbnailMIMEType string
|
||||
mxc, encryptedFileInfo, err = intent.UploadMediaStream(ctx, t.inner.roomID, int64(t.inner.fileInfo.Size), needStickerConvert || needsDimensions, func(file io.Writer) (*bridgev2.FileStreamResult, error) {
|
||||
_, err := io.Copy(file, reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stream download: %w", err)
|
||||
}
|
||||
var replacementFile string
|
||||
if needsDimensions {
|
||||
osFile := file.(*os.File)
|
||||
_, err = osFile.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to seek to start of file for sticker conversion: %w", err)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(osFile)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to decode image to detect dimensions")
|
||||
} else {
|
||||
t.inner.fileInfo.Width = cfg.Width
|
||||
t.inner.fileInfo.Height = cfg.Height
|
||||
t.inner.adjustStickerSize()
|
||||
}
|
||||
}
|
||||
if needStickerConvert {
|
||||
osFile := file.(*os.File)
|
||||
_, err = osFile.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to seek to start of file for sticker conversion: %w", err)
|
||||
}
|
||||
var converted *ConvertedSticker
|
||||
if t.inner.fileInfo.MimeType == "video/webm" {
|
||||
converted = t.inner.animatedStickerConfig.convertWebm(ctx, osFile)
|
||||
} else {
|
||||
t.inner.fileInfo.MimeType = "application/x-tgsticker" // This is expected to get overridden by convert
|
||||
converted = t.inner.animatedStickerConfig.convert(ctx, osFile)
|
||||
}
|
||||
if converted != nil {
|
||||
replacementFile = converted.NewPath
|
||||
t.inner.fileInfo.MimeType = converted.MIMEType
|
||||
t.inner.fileInfo.Width = converted.Width
|
||||
t.inner.fileInfo.Height = converted.Height
|
||||
t.inner.fileInfo.Size = converted.Size
|
||||
thumbnailData = converted.ThumbnailData
|
||||
thumbnailMIMEType = converted.ThumbnailMIMEType
|
||||
}
|
||||
}
|
||||
return &bridgev2.FileStreamResult{
|
||||
FileName: t.inner.filename,
|
||||
MimeType: t.inner.fileInfo.MimeType,
|
||||
ReplacementFile: replacementFile,
|
||||
}, err
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to upload media to Matrix: %w", err)
|
||||
}
|
||||
if thumbnailData != nil {
|
||||
thumbnailMXC, thumbnailFileInfo, err := intent.UploadMedia(ctx, t.inner.roomID, thumbnailData, t.inner.filename, thumbnailMIMEType)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to upload animated sticker thumbnail to Matrix")
|
||||
} else {
|
||||
t.inner = t.inner.WithThumbnail(thumbnailMXC, thumbnailFileInfo, &event.FileInfo{
|
||||
MimeType: thumbnailMIMEType,
|
||||
Width: t.inner.fileInfo.Width,
|
||||
Height: t.inner.fileInfo.Height,
|
||||
Size: len(thumbnailData),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If it's an unencrypted file, cache the MXC URI corresponding to the
|
||||
// location ID.
|
||||
if len(mxc) > 0 {
|
||||
err = db.TelegramFile.Insert(ctx, &store.TelegramFile{
|
||||
LocationID: locationID,
|
||||
MXC: mxc,
|
||||
MIMEType: t.inner.fileInfo.MimeType,
|
||||
Size: t.inner.fileInfo.Size,
|
||||
Width: t.inner.fileInfo.Width,
|
||||
Height: t.inner.fileInfo.Height,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to insert Telegram file into database")
|
||||
}
|
||||
}
|
||||
return mxc, encryptedFileInfo, &t.inner.fileInfo, nil
|
||||
}
|
||||
|
||||
// Stream streams the media from Telegram to an [io.Reader].
|
||||
func (t *ReadyTransferer) Stream(ctx context.Context) (r io.Reader, mimeType string, fileSize int, err error) {
|
||||
var storageFileTypeClass tg.StorageFileTypeClass
|
||||
storageFileTypeClass, r, err = downloader.NewDownloader().WithPartSize(1024*1024).Download(t.inner.client, t.loc).StreamToReader(ctx)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
if t.inner.fileInfo.MimeType == "" || t.inner.fileInfo.MimeType == "application/octet-stream" {
|
||||
switch storageFileTypeClass.(type) {
|
||||
case *tg.StorageFileJpeg:
|
||||
t.inner.fileInfo.MimeType = "image/jpeg"
|
||||
case *tg.StorageFileGif:
|
||||
t.inner.fileInfo.MimeType = "image/gif"
|
||||
case *tg.StorageFilePng:
|
||||
t.inner.fileInfo.MimeType = "image/png"
|
||||
case *tg.StorageFilePdf:
|
||||
t.inner.fileInfo.MimeType = "application/pdf"
|
||||
case *tg.StorageFileMp3:
|
||||
t.inner.fileInfo.MimeType = "audio/mpeg"
|
||||
case *tg.StorageFileMov:
|
||||
t.inner.fileInfo.MimeType = "video/quicktime"
|
||||
case *tg.StorageFileMp4:
|
||||
t.inner.fileInfo.MimeType = "video/mp4"
|
||||
case *tg.StorageFileWebp:
|
||||
t.inner.fileInfo.MimeType = "image/webp"
|
||||
default:
|
||||
t.inner.fileInfo.MimeType = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
return r, t.inner.fileInfo.MimeType, t.inner.fileInfo.Size, nil
|
||||
}
|
||||
|
||||
func (t *ReadyTransferer) ToDirectMediaResponse(ctx context.Context) (mediaproxy.GetMediaResponse, error) {
|
||||
if t == nil {
|
||||
return nil, fmt.Errorf("invalid direct media request")
|
||||
}
|
||||
log := zerolog.Ctx(ctx)
|
||||
r, mimeType, size, err := t.Stream(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to download media")
|
||||
return nil, err
|
||||
}
|
||||
log.Debug().
|
||||
Str("mime_type", mimeType).
|
||||
Int("size", size).
|
||||
Msg("Started downloading media successfully")
|
||||
|
||||
if t.inner.animatedStickerConfig != nil {
|
||||
return &mediaproxy.GetMediaResponseFile{
|
||||
Callback: func(w *os.File) (*mediaproxy.FileMeta, error) {
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write animated sticker data to file: %w", err)
|
||||
}
|
||||
_, err = w.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to seek to start of file for sticker conversion: %w", err)
|
||||
}
|
||||
var converted *ConvertedSticker
|
||||
if t.inner.fileInfo.MimeType == "video/webm" {
|
||||
converted = t.inner.animatedStickerConfig.convertWebm(ctx, w)
|
||||
} else {
|
||||
t.inner.fileInfo.MimeType = "application/x-tgsticker" // This is expected to get overridden by convert
|
||||
converted = t.inner.animatedStickerConfig.convert(ctx, w)
|
||||
}
|
||||
if converted == nil {
|
||||
return &mediaproxy.FileMeta{ContentType: t.inner.fileInfo.MimeType}, nil
|
||||
}
|
||||
return &mediaproxy.FileMeta{
|
||||
ContentType: converted.MIMEType,
|
||||
ReplacementFile: converted.NewPath,
|
||||
}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &mediaproxy.GetMediaResponseData{
|
||||
Reader: io.NopCloser(r),
|
||||
ContentType: mimeType,
|
||||
ContentLength: int64(size),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DownloadBytes downloads the media from Telegram to a byte buffer.
|
||||
func (t *ReadyTransferer) DownloadBytes(ctx context.Context) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
_, err := downloader.NewDownloader().Download(t.inner.client, t.loc).Stream(ctx, &buf)
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
// DirectDownloadURL returns the direct download URL for the media.
|
||||
func (t *ReadyTransferer) DirectDownloadURL(ctx context.Context, loggedInUserID int64, portal *bridgev2.Portal, msgID int, thumbnail bool, telegramMediaID int64) (id.ContentURIString, *event.FileInfo, error) {
|
||||
peerType, chatID, _, err := ids.ParsePortalID(portal.ID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
mediaID, err := ids.DirectMediaInfo{
|
||||
PeerType: peerType,
|
||||
PeerID: chatID,
|
||||
UserID: loggedInUserID,
|
||||
MessageID: int64(msgID),
|
||||
Thumbnail: thumbnail,
|
||||
ID: telegramMediaID,
|
||||
}.AsMediaID()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
mxc, err := portal.Bridge.Matrix.GenerateContentURI(ctx, mediaID)
|
||||
if t.inner.fileInfo.MimeType == "" {
|
||||
t.inner.fileInfo.MimeType = "application/octet-stream"
|
||||
}
|
||||
if t.inner.fileInfo.MimeType == "application/x-tgsticker" {
|
||||
t.inner.fileInfo.MimeType = "video/lottie+json"
|
||||
}
|
||||
return mxc, &t.inner.fileInfo, err
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// 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"
|
||||
"sync"
|
||||
|
||||
"go.mau.fi/util/exsync"
|
||||
"go.mau.fi/util/jsontime"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"maunium.net/go/mautrix/bridgev2/database"
|
||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/session"
|
||||
)
|
||||
|
||||
func (tc *TelegramConnector) GetDBMetaTypes() database.MetaTypes {
|
||||
return database.MetaTypes{
|
||||
Ghost: func() any { return &GhostMetadata{} },
|
||||
Portal: func() any { return &PortalMetadata{} },
|
||||
Message: func() any { return &MessageMetadata{} },
|
||||
Reaction: nil,
|
||||
UserLogin: func() any { return &UserLoginMetadata{} },
|
||||
}
|
||||
}
|
||||
|
||||
type GhostMetadata struct {
|
||||
IsPremium bool `json:"is_premium,omitempty"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
NotMin bool `json:"not_min,omitempty"`
|
||||
|
||||
ContactSource int64 `json:"contact_source,omitempty"`
|
||||
SourceIsContact bool `json:"source_is_contact,omitempty"`
|
||||
}
|
||||
|
||||
func (gm *GhostMetadata) IsMin() bool {
|
||||
return !gm.NotMin
|
||||
}
|
||||
|
||||
type PortalMetadata struct {
|
||||
IsSuperGroup bool `json:"is_supergroup,omitempty"`
|
||||
IsForumGeneral bool `json:"is_forum_general,omitempty"`
|
||||
ReadUpTo int `json:"read_up_to,omitempty"` // FIXME this shouldn't be here
|
||||
AllowedReactions []string `json:"allowed_reactions"`
|
||||
LastSync jsontime.Unix `json:"last_sync,omitempty"`
|
||||
FullSynced bool `json:"full_synced,omitempty"`
|
||||
ParticipantsCount int `json:"member_count,omitempty"`
|
||||
|
||||
SponsoredMessagePollTS jsontime.Unix `json:"sponsored_message_poll_ts,omitempty"`
|
||||
SponsoredMessageEventID id.EventID `json:"sponsored_message_event_id,omitempty"`
|
||||
SponsoredMessageRandomID []byte `json:"sponsored_message_random_id,omitempty"`
|
||||
LastMessageOnSponsorFetch networkid.MessageID `json:"last_message_on_sponsor_fetch,omitempty"`
|
||||
|
||||
sponsoredMessageLock sync.Mutex
|
||||
sponsoredMessageSeen *exsync.Set[int64]
|
||||
}
|
||||
|
||||
func (pm *PortalMetadata) SetIsSuperGroup(isSupergroup bool) (changed bool) {
|
||||
changed = pm.IsSuperGroup != isSupergroup
|
||||
pm.IsSuperGroup = isSupergroup
|
||||
return changed
|
||||
}
|
||||
|
||||
func (pm *PortalMetadata) SetIsForumGeneral(isForumGeneral bool) (changed bool) {
|
||||
changed = pm.IsForumGeneral != isForumGeneral
|
||||
pm.IsForumGeneral = isForumGeneral
|
||||
return changed
|
||||
}
|
||||
|
||||
type MessageMetadata struct {
|
||||
ContentHash []byte `json:"content_hash,omitempty"`
|
||||
ContentURI id.ContentURIString `json:"content_uri,omitempty"`
|
||||
}
|
||||
|
||||
type UserLoginMetadata struct {
|
||||
LoginPhone string `json:"phone,omitempty"`
|
||||
LoginMethod string `json:"login_method,omitempty"`
|
||||
IsBot bool `json:"is_bot,omitempty"`
|
||||
Session UserLoginSession `json:"session"`
|
||||
TakeoutID int64 `json:"takeout_id,omitempty"`
|
||||
|
||||
DialogSyncComplete bool `json:"takeout_portal_crawl_done,omitempty"`
|
||||
DialogSyncCursor networkid.PortalID `json:"takeout_portal_crawl_cursor,omitempty"`
|
||||
DialogSyncCount int `json:"dialog_sync_count,omitempty"`
|
||||
|
||||
PinnedDialogs []networkid.PortalID `json:"pinned_dialogs,omitempty"`
|
||||
|
||||
PushEncryptionKey []byte `json:"push_encryption_key,omitempty"`
|
||||
}
|
||||
|
||||
func (u *UserLoginMetadata) ResetOnLogout() {
|
||||
u.Session.AuthKey = nil
|
||||
u.TakeoutID = 0
|
||||
u.DialogSyncComplete = false
|
||||
u.DialogSyncCursor = networkid.PortalID("")
|
||||
u.DialogSyncCount = 0
|
||||
u.PushEncryptionKey = nil
|
||||
}
|
||||
|
||||
type UserLoginSession struct {
|
||||
AuthKey []byte `json:"auth_key,omitempty"`
|
||||
Datacenter int `json:"dc_id,omitempty"`
|
||||
ServerAddress string `json:"server_address,omitempty"`
|
||||
ServerPort int `json:"port,omitempty"`
|
||||
Salt int64 `json:"salt,omitempty"`
|
||||
}
|
||||
|
||||
func (u UserLoginSession) HasAuthKey() bool {
|
||||
return len(u.AuthKey) == 256
|
||||
}
|
||||
|
||||
func (s *UserLoginSession) Load(_ context.Context) (*session.Data, error) {
|
||||
if !s.HasAuthKey() {
|
||||
return nil, session.ErrNotFound
|
||||
}
|
||||
keyID := crypto.Key(s.AuthKey).ID()
|
||||
return &session.Data{
|
||||
DC: s.Datacenter,
|
||||
Addr: s.ServerAddress,
|
||||
AuthKey: s.AuthKey,
|
||||
AuthKeyID: keyID[:],
|
||||
Salt: s.Salt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *UserLoginSession) Save(ctx context.Context, data *session.Data) error {
|
||||
s.Datacenter = data.DC
|
||||
s.ServerAddress = data.Addr
|
||||
s.AuthKey = data.AuthKey
|
||||
s.Salt = data.Salt
|
||||
// TODO save UserLogin to database?
|
||||
return nil
|
||||
}
|
||||
|
||||
func updatePortalLastSyncAt(_ context.Context, portal *bridgev2.Portal) bool {
|
||||
meta := portal.Metadata.(*PortalMetadata)
|
||||
meta.LastSync = jsontime.UnixNow()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
|
||||
// Copyright (C) 2026 Vladislav Agarkov
|
||||
//
|
||||
// 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 (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/dcs"
|
||||
)
|
||||
|
||||
// decodeMTProxySecret parses an MTProxy secret string into raw bytes.
|
||||
// MTProxy secrets are binary (faketls secrets begin with 0xEE, secured with 0xDD)
|
||||
// and cannot be carried verbatim in a YAML string field, so we accept the standard
|
||||
// hex encoding (optionally prefixed with "ee"/"dd") used by mtg/MTProxy tooling.
|
||||
func decodeMTProxySecret(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("mtproxy secret is empty")
|
||||
}
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtproxy secret must be hex-encoded: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func GetProxyDialFunc(cfg ProxyConfig) (dcs.DialFunc, error) {
|
||||
switch cfg.Type {
|
||||
// we can't proxy HTTP through mtproxy
|
||||
case "disabled", "mtproxy":
|
||||
return nil, nil
|
||||
case "socks5":
|
||||
var auth *proxy.Auth
|
||||
if cfg.Username != "" && cfg.Password != "" {
|
||||
auth = &proxy.Auth{User: cfg.Username, Password: cfg.Password}
|
||||
}
|
||||
sock5, err := proxy.SOCKS5("tcp", cfg.Address, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sock5.(proxy.ContextDialer).DialContext, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy type %s", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func GetProxyResolver(cfg ProxyConfig) (dcs.Resolver, error) {
|
||||
switch cfg.Type {
|
||||
case "disabled", "socks5":
|
||||
dialer, err := GetProxyDialFunc(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver := dcs.Plain(dcs.PlainOptions{Dial: dialer})
|
||||
return resolver, nil
|
||||
case "mtproxy":
|
||||
secret, err := decodeMTProxySecret(cfg.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dcs.MTProxy(cfg.Address, secret, dcs.MTProxyOptions{})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy type %s", cfg.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeMTProxySecret(t *testing.T) {
|
||||
// faketls secret: 0xee + 16 bytes + cloak domain ("working-name.ru" = 15 bytes)
|
||||
hexSecret := "ee971746d927f4c0138b18447bfe1269bc70312e776f726b696e672d6e616d652e7275"
|
||||
want := []byte{
|
||||
0xee,
|
||||
0x97, 0x17, 0x46, 0xd9, 0x27, 0xf4, 0xc0, 0x13,
|
||||
0x8b, 0x18, 0x44, 0x7b, 0xfe, 0x12, 0x69, 0xbc,
|
||||
0x70, 0x31, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x69,
|
||||
0x6e, 0x67, 0x2d, 0x6e, 0x61, 0x6d, 0x65, 0x2e,
|
||||
0x72, 0x75,
|
||||
}
|
||||
got, err := decodeMTProxySecret(hexSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("decoded bytes mismatch:\n got=%x\nwant=%x", got, want)
|
||||
}
|
||||
|
||||
if _, err := decodeMTProxySecret(" " + hexSecret + "\n"); err != nil {
|
||||
t.Fatalf("whitespace should be tolerated: %v", err)
|
||||
}
|
||||
|
||||
if _, err := decodeMTProxySecret(""); err == nil {
|
||||
t.Fatal("expected error for empty secret")
|
||||
}
|
||||
if _, err := decodeMTProxySecret("not-hex!!"); err == nil {
|
||||
t.Fatal("expected error for non-hex secret")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// 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/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.mau.fi/util/exslices"
|
||||
"go.mau.fi/util/random"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
"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/crypto"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
var (
|
||||
_ bridgev2.PushableNetworkAPI = (*TelegramClient)(nil)
|
||||
_ bridgev2.PushParsingNetwork = (*TelegramConnector)(nil)
|
||||
)
|
||||
|
||||
var PushAppSandbox = false
|
||||
|
||||
type PushCustomData struct {
|
||||
MessageID int `json:"msg_id,string"`
|
||||
|
||||
ChannelID int64 `json:"channel_id,string"`
|
||||
TopicID int `json:"top_msg_id,string"`
|
||||
ChatID int64 `json:"chat_id,string"`
|
||||
FromID int64 `json:"from_id,string"`
|
||||
|
||||
ChatFromBroadcastID int64 `json:"chat_from_broadcast_id,string"`
|
||||
ChatFromGroupID int64 `json:"chat_from_group_id,string"`
|
||||
ChatFromID int64 `json:"chat_from_id,string"`
|
||||
}
|
||||
|
||||
type Aps struct {
|
||||
Alert Alert `json:"alert"`
|
||||
ThreadID string `json:"thread-id"`
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type PushNotificationData struct {
|
||||
Aps *Aps `json:"aps"` // Only present for APNs
|
||||
|
||||
LocKey string `json:"loc_key"`
|
||||
LocArgs []string `json:"loc_args"`
|
||||
Custom PushCustomData `json:"custom"`
|
||||
Sound string `json:"sound"`
|
||||
UserID int `json:"user_id,string"`
|
||||
}
|
||||
|
||||
var PushMessageFormats = map[string]string{
|
||||
"AUTH_REGION": "New login from unrecognized device %[1]v, location: %[2]v",
|
||||
"AUTH_UNKNOWN": "New login from unrecognized device %[1]v",
|
||||
"CHANNEL_MESSAGES": "%[1]v posted an album",
|
||||
"CHANNEL_MESSAGE_AUDIO": "%[1]v posted a voice message",
|
||||
"CHANNEL_MESSAGE_CONTACT": "%[1]v posted a contact %[2]v",
|
||||
"CHANNEL_MESSAGE_DOC": "%[1]v posted a file",
|
||||
"CHANNEL_MESSAGE_DOCS": "%[1]v posted %[2]v files",
|
||||
"CHANNEL_MESSAGE_FWDS": "%[1]v posted %[2]v forwarded messages",
|
||||
"CHANNEL_MESSAGE_GAME": "%[1]v invited you to play %[2]v",
|
||||
"CHANNEL_MESSAGE_GAME_SCORE": "%[1]v scored %[3]v in game %[2]v",
|
||||
"CHANNEL_MESSAGE_GEO": "%[1]v posted a location",
|
||||
"CHANNEL_MESSAGE_GEOLIVE": "%[1]v posted a live location",
|
||||
"CHANNEL_MESSAGE_GIF": "%[1]v posted a GIF",
|
||||
"CHANNEL_MESSAGE_GIVEAWAY": "%[1]v posted a giveaway of %[2]vx %[3]vm Premium subscriptions",
|
||||
"CHANNEL_MESSAGE_GIVEAWAY_STARS": "%[1]v posted a giveaway of %[3]v stars %[2]v",
|
||||
"CHANNEL_MESSAGE_NOTEXT": "%[1]v posted a message",
|
||||
"CHANNEL_MESSAGE_PAID_MEDIA": "%[1]v posted a paid post for %[2]v star",
|
||||
"CHANNEL_MESSAGE_PHOTO": "%[1]v posted a photo",
|
||||
"CHANNEL_MESSAGE_PHOTOS": "%[1]v posted %[2]v photos",
|
||||
"CHANNEL_MESSAGE_PLAYLIST": "%[1]v posted %[2]v music files",
|
||||
"CHANNEL_MESSAGE_POLL": "%[1]v posted a poll %[2]v",
|
||||
"CHANNEL_MESSAGE_QUIZ": "%[1]v posted a quiz %[2]v",
|
||||
"CHANNEL_MESSAGE_ROUND": "%[1]v posted a video message",
|
||||
"CHANNEL_MESSAGE_STICKER": "%[1]v posted a %[2]v sticker",
|
||||
"CHANNEL_MESSAGE_STORY": "%[1]v shared a story",
|
||||
"CHANNEL_MESSAGE_TEXT": "%[1]v: %[2]v",
|
||||
"CHANNEL_MESSAGE_VIDEO": "%[1]v posted a video",
|
||||
"CHANNEL_MESSAGE_VIDEOS": "%[1]v posted %[2]v videos",
|
||||
"CHAT_ADD_MEMBER": "%[1]v invited %[3]v to the group %[2]v",
|
||||
"CHAT_ADD_YOU": "%[1]v invited you to the group %[2]v",
|
||||
"CHAT_CREATED": "%[1]v invited you to the group %[2]v",
|
||||
"CHAT_DELETE_MEMBER": "%[1]v removed %[3]v from the group %[2]v",
|
||||
"CHAT_DELETE_YOU": "%[1]v removed you from the group %[2]v",
|
||||
"CHAT_JOINED": "%[1]v joined the group %[2]v",
|
||||
"CHAT_LEFT": "%[1]v left the group %[2]v",
|
||||
"CHAT_MESSAGES": "%[1]v sent an album to the group %[2]v",
|
||||
"CHAT_MESSAGE_AUDIO": "%[1]v sent a voice message to the group %[2]v",
|
||||
"CHAT_MESSAGE_CONTACT": "%[1]v shared a contact %[3]v in the group %[2]v",
|
||||
"CHAT_MESSAGE_DOC": "%[1]v sent a file to the group %[2]v",
|
||||
"CHAT_MESSAGE_DOCS": "%[1]v sent %[3]v files to the group %[2]v",
|
||||
"CHAT_MESSAGE_FWDS": "%[1]v forwarded %[3]v messages to the group %[2]v",
|
||||
"CHAT_MESSAGE_GAME": "%[1]v invited the group %[2]v to play %[3]v",
|
||||
"CHAT_MESSAGE_GAME_SCORE": "%[1]v scored %[4]v in game %[3]v in the group %[2]v",
|
||||
"CHAT_MESSAGE_GEO": "%[1]v sent a location to the group %[2]v",
|
||||
"CHAT_MESSAGE_GEOLIVE": "%[1]v shared a live location with the group %[2]v",
|
||||
"CHAT_MESSAGE_GIF": "%[1]v sent a GIF to the group %[2]v",
|
||||
"CHAT_MESSAGE_GIVEAWAY": "%[1]v sent a giveaway of %[3]vx %[4]vm Premium subscriptions to the group %[2]v",
|
||||
"CHAT_MESSAGE_GIVEAWAY_STARS": "%[1]v sent a giveaway of %[4]v stars %[3]v to the group %[2]v",
|
||||
"CHAT_MESSAGE_INVOICE": "%[1]v sent an invoice to the group %[2]v for %[3]v",
|
||||
"CHAT_MESSAGE_NOTEXT": "%[1]v sent a message to the group %[2]v",
|
||||
"CHAT_MESSAGE_PAID_MEDIA": "%[1]v posted a paid post in %[2]v group for %[3]v star",
|
||||
"CHAT_MESSAGE_PHOTO": "%[1]v sent a photo to the group %[2]v",
|
||||
"CHAT_MESSAGE_PHOTOS": "%[1]v sent %[3]v photos to the group %[2]v",
|
||||
"CHAT_MESSAGE_PLAYLIST": "%[1]v sent %[3]v music files to the group %[2]v",
|
||||
"CHAT_MESSAGE_POLL": "%[1]v sent a poll %[3]v to the group %[2]v",
|
||||
"CHAT_MESSAGE_QUIZ": "%[1]v sent a quiz %[3]v to the group %[2]v",
|
||||
"CHAT_MESSAGE_ROUND": "%[1]v sent a video message to the group %[2]v",
|
||||
"CHAT_MESSAGE_STICKER": "%[1]v sent a %[3]v sticker to the group %[2]v",
|
||||
"CHAT_MESSAGE_STORY": "%[1]v shared a story to the group",
|
||||
"CHAT_MESSAGE_TEXT": "%[1]v @ %[2]v: %[3]v",
|
||||
"CHAT_MESSAGE_VIDEO": "%[1]v sent a video to the group %[2]v",
|
||||
"CHAT_MESSAGE_VIDEOS": "%[1]v sent %[3]v videos to the group %[2]v",
|
||||
"CHAT_PHOTO_EDITED": "%[1]v changed the group photo for %[2]v",
|
||||
"CHAT_REACT_AUDIO": "%[1]v: %[3]v to your voice message in %[2]v",
|
||||
"CHAT_REACT_CONTACT": "%[1]v: %[3]v to your contact %[4]v in %[2]v",
|
||||
"CHAT_REACT_DOC": "%[1]v: %[3]v to your file in %[2]v",
|
||||
"CHAT_REACT_GAME": "%[1]v: %[3]v to your game in %[2]v",
|
||||
"CHAT_REACT_GEO": "%[1]v: %[3]v to your map in %[2]v",
|
||||
"CHAT_REACT_GEOLIVE": "%[1]v: %[3]v to your live location in %[2]v",
|
||||
"CHAT_REACT_GIF": "%[1]v: %[3]v to your GIF in %[2]v",
|
||||
"CHAT_REACT_GIVEAWAY": "%[1]v reacted %[3]v in group %[2]v to your giveaway",
|
||||
"CHAT_REACT_INVOICE": "%[1]v: %[3]v to your invoice in %[2]v",
|
||||
"CHAT_REACT_NOTEXT": "%[1]v: %[3]v to your message in %[2]v",
|
||||
"CHAT_REACT_PAID_MEDIA": "%[1]v reacted %[3]v in group %[2]v to your paid post for %[4]v star",
|
||||
"CHAT_REACT_PHOTO": "%[1]v: %[3]v to your photo in %[2]v",
|
||||
"CHAT_REACT_POLL": "%[1]v: %[3]v to your poll %[4]v in %[2]v",
|
||||
"CHAT_REACT_QUIZ": "%[1]v: %[3]v to your quiz %[4]v in %[2]v",
|
||||
"CHAT_REACT_ROUND": "%[1]v: %[3]v to your video message in %[2]v",
|
||||
"CHAT_REACT_STICKER": "%[1]v: %[3]v to your %[4]v sticker in %[2]v",
|
||||
"CHAT_REACT_TEXT": "%[1]v: %[3]v in %[2]v to your \"%[4]v\"",
|
||||
"CHAT_REACT_VIDEO": "%[1]v: %[3]v to your video in %[2]v",
|
||||
"CHAT_REQ_JOINED": "%[2]v|%[1]v was accepted into the group",
|
||||
"CHAT_RETURNED": "%[1]v returned to the group %[2]v",
|
||||
"CHAT_TITLE_EDITED": "%[1]v renamed the group %[2]v",
|
||||
"CHAT_VOICECHAT_END": "%[1]v ended a voice chat in the group %[2]v",
|
||||
"CHAT_VOICECHAT_INVITE": "%[1]v invited %[3]v to a voice chat in the group %[2]v",
|
||||
"CHAT_VOICECHAT_INVITE_YOU": "%[1]v invited you to a voice chat in the group %[2]v",
|
||||
"CHAT_VOICECHAT_START": "%[1]v started a voice chat in the group %[2]v",
|
||||
"CONTACT_JOINED": "%[1]v joined Telegram!",
|
||||
"ENCRYPTED_MESSAGE": "You have a new message",
|
||||
"ENCRYPTION_ACCEPT": "You have a new message",
|
||||
"ENCRYPTION_REQUEST": "You have a new message",
|
||||
"LOCKED_MESSAGE": "You have a new message",
|
||||
"MESSAGES": "%[1]v sent you an album",
|
||||
"MESSAGE_AUDIO": "%[1]v sent you a voice message",
|
||||
"MESSAGE_CONTACT": "%[1]v shared a contact %[2]v with you",
|
||||
"MESSAGE_DOC": "%[1]v sent you a file",
|
||||
"MESSAGE_DOCS": "%[1]v sent you %[2]v files",
|
||||
"MESSAGE_FWDS": "%[1]v forwarded you %[2]v messages",
|
||||
"MESSAGE_GAME": "%[1]v invited you to play %[2]v",
|
||||
"MESSAGE_GAME_SCORE": "%[1]v scored %[3]v in game %[2]v",
|
||||
"MESSAGE_GEO": "%[1]v sent you a location",
|
||||
"MESSAGE_GEOLIVE": "%[1]v sent you a live location",
|
||||
"MESSAGE_GIF": "%[1]v sent you a GIF",
|
||||
"MESSAGE_GIFTCODE": "%[1]v sent you a Gift Code for %[2]v of Telegram Premium",
|
||||
"MESSAGE_GIVEAWAY": "%[1]v sent you a giveaway of %[2]vx %[3]vm Premium subscriptions",
|
||||
"MESSAGE_GIVEAWAY_STARS": "%[1]v sent you a giveaway of %[3]v stars %[2]v",
|
||||
"MESSAGE_INVOICE": "%[1]v sent you an invoice for %[2]v",
|
||||
"MESSAGE_NOTEXT": "%[1]v sent you a message",
|
||||
"MESSAGE_PAID_MEDIA": "%[1]v sent you a paid post for %[2]v star",
|
||||
"MESSAGE_PHOTO": "%[1]v sent you a photo",
|
||||
"MESSAGE_PHOTOS": "%[1]v sent you %[2]v photos",
|
||||
"MESSAGE_PHOTO_SECRET": "%[1]v sent you a self-destructing photo",
|
||||
"MESSAGE_PLAYLIST": "%[1]v sent you %[2]v music files",
|
||||
"MESSAGE_POLL": "%[1]v sent you a poll %[2]v",
|
||||
"MESSAGE_QUIZ": "%[1]v sent you a quiz %[2]v",
|
||||
"MESSAGE_RECURRING_PAY": "You were charged %[2]v by %[1]v",
|
||||
"MESSAGE_ROUND": "%[1]v sent you a video message",
|
||||
"MESSAGE_SAME_WALLPAPER": "%[1]v set a same wallpaper for this chat",
|
||||
"MESSAGE_SCREENSHOT": "%[1]v took a screenshot",
|
||||
"MESSAGE_STARGIFT": "%[1]v sent you a Gift worth %[2]v Stars",
|
||||
"MESSAGE_STICKER": "%[1]v sent you a %[2]v sticker",
|
||||
"MESSAGE_STORY": "%[1]v shared a story with you",
|
||||
"MESSAGE_STORY_MENTION": "%[1]v mentioned you in a story",
|
||||
"MESSAGE_TEXT": "%[1]v: %[2]v",
|
||||
"MESSAGE_VIDEO": "%[1]v sent you a video",
|
||||
"MESSAGE_VIDEOS": "%[1]v sent you %[2]v videos",
|
||||
"MESSAGE_VIDEO_SECRET": "%[1]v sent you a self-destructing video",
|
||||
"MESSAGE_WALLPAPER": "%[1]v set a new wallpaper for this chat",
|
||||
"PHONE_CALL_MISSED": "You missed a call from %[1]v",
|
||||
"PHONE_CALL_REQUEST": "%[1]v is calling you!",
|
||||
"PINNED_AUDIO": "%[1]v pinned a voice message in the group %[2]v",
|
||||
"PINNED_CONTACT": "%[1]v pinned a contact %[3]v in the group %[2]v",
|
||||
"PINNED_DOC": "%[1]v pinned a file in the group %[2]v",
|
||||
"PINNED_GAME": "%[1]v pinned a game in the group %[2]v",
|
||||
"PINNED_GAME_SCORE": "%[1]v pinned a game score in the group %[2]v",
|
||||
"PINNED_GEO": "%[1]v pinned a map in the group %[2]v",
|
||||
"PINNED_GEOLIVE": "%[1]v pinned a live location in the group %[2]v",
|
||||
"PINNED_GIF": "%[1]v pinned a GIF in the group %[2]v",
|
||||
"PINNED_GIVEAWAY": "%[1]v pinned a giveaway in the group %[2]v",
|
||||
"PINNED_INVOICE": "%[1]v pinned an invoice in the group %[2]v",
|
||||
"PINNED_NOTEXT": "%[1]v pinned a message in the group %[2]v",
|
||||
"PINNED_PAID_MEDIA": "%[1]v pinned a paid post for %[3]v star in the group %[2]v",
|
||||
"PINNED_PHOTO": "%[1]v pinned a photo in the group %[2]v",
|
||||
"PINNED_POLL": "%[1]v pinned a poll %[3]v in the group %[2]v",
|
||||
"PINNED_QUIZ": "%[1]v pinned a quiz %[3]v in the group %[2]v",
|
||||
"PINNED_ROUND": "%[1]v pinned a video message in the group %[2]v",
|
||||
"PINNED_STICKER": "%[1]v pinned a %[3]v sticker in the group %[2]v",
|
||||
"PINNED_TEXT": "%[1]v pinned \"%[3]v\" in the group %[2]v",
|
||||
"PINNED_VIDEO": "%[1]v pinned a video in the group %[2]v",
|
||||
"REACT_AUDIO": "%[1]v: %[2]v to your voice message",
|
||||
"REACT_CONTACT": "%[1]v: %[2]v to your contact %[3]v",
|
||||
"REACT_DOC": "%[1]v: %[2]v to your file",
|
||||
"REACT_GAME": "%[1]v: %[2]v to your game",
|
||||
"REACT_GEO": "%[1]v: %[2]v to your map",
|
||||
"REACT_GEOLIVE": "%[1]v: %[2]v to your live location",
|
||||
"REACT_GIF": "%[1]v: %[2]v to your GIF",
|
||||
"REACT_GIVEAWAY": "%[1]v reacted %[2]v to your giveaway",
|
||||
"REACT_HIDDEN": "New reaction to your message",
|
||||
"REACT_INVOICE": "%[1]v: %[2]v to your invoice",
|
||||
"REACT_NOTEXT": "%[1]v: %[2]v to your message",
|
||||
"REACT_PHOTO": "%[1]v: %[2]v to your photo",
|
||||
"REACT_POLL": "%[1]v: %[2]v to your poll %[3]v",
|
||||
"REACT_QUIZ": "%[1]v: %[2]v to your quiz %[3]v",
|
||||
"REACT_ROUND": "%[1]v: %[2]v to your video message",
|
||||
"REACT_STICKER": "%[1]v: %[2]v to your %[3]v sticker",
|
||||
"REACT_STORY": "%[1]v: %[2]v to your story",
|
||||
"REACT_STORY_HIDDEN": "New reaction to your story",
|
||||
"REACT_TEXT": "%[1]v: %[2]v to your \"%[3]v\"",
|
||||
"REACT_VIDEO": "%[1]v: %[2]v to your video",
|
||||
"STORY_HIDDEN_AUTHOR": "A new story was posted",
|
||||
"STORY_NOTEXT": "%[1]v posted a story",
|
||||
}
|
||||
|
||||
var FullSyncOnConnectBackground = false
|
||||
|
||||
func (tc *TelegramClient) ConnectBackground(ctx context.Context, params *bridgev2.ConnectBackgroundParams) error {
|
||||
data, _ := params.ExtraData.(*PushNotificationData)
|
||||
var relatedPortal *bridgev2.Portal
|
||||
var sender *bridgev2.Ghost
|
||||
var messageID networkid.MessageID
|
||||
var messageText, notificationText, notificationTitle string
|
||||
if notifs, ok := tc.main.Bridge.Matrix.(bridgev2.MatrixConnectorWithNotifications); ok && data != nil {
|
||||
if data.Aps != nil {
|
||||
notificationTitle = data.Aps.Alert.Title
|
||||
notificationText = data.Aps.Alert.Body
|
||||
if data.LocKey == "" {
|
||||
messageText = data.Aps.Alert.Body
|
||||
}
|
||||
}
|
||||
tpl, ok := PushMessageFormats[data.LocKey]
|
||||
if ok && (len(data.LocArgs) > 0 || !strings.Contains(tpl, "%[1]")) {
|
||||
notificationText = fmt.Sprintf(tpl, exslices.CastToAny(data.LocArgs)...)
|
||||
}
|
||||
switch data.LocKey {
|
||||
case "MESSAGE_TEXT", "CHANNEL_MESSAGE_TEXT":
|
||||
messageText = data.LocArgs[1]
|
||||
case "CHAT_MESSAGE_TEXT":
|
||||
messageText = data.LocArgs[2]
|
||||
}
|
||||
var err error
|
||||
if data.Custom.ChannelID != 0 {
|
||||
relatedPortal, err = tc.main.Bridge.GetPortalByKey(ctx, tc.makePortalKeyFromID(ids.PeerTypeChannel, data.Custom.ChannelID, data.Custom.TopicID))
|
||||
} else if data.Custom.ChatID != 0 {
|
||||
relatedPortal, err = tc.main.Bridge.GetPortalByKey(ctx, tc.makePortalKeyFromID(ids.PeerTypeChat, data.Custom.ChatID, 0))
|
||||
} else if data.Custom.FromID != 0 {
|
||||
relatedPortal, err = tc.main.Bridge.GetPortalByKey(ctx, tc.makePortalKeyFromID(ids.PeerTypeUser, data.Custom.FromID, 0))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get related portal: %w", err)
|
||||
}
|
||||
if data.Custom.ChatFromBroadcastID != 0 {
|
||||
sender, err = tc.main.Bridge.GetGhostByID(ctx, ids.MakeChannelUserID(data.Custom.FromID))
|
||||
} else if data.Custom.ChatFromGroupID != 0 {
|
||||
sender, err = tc.main.Bridge.GetGhostByID(ctx, ids.MakeChannelUserID(data.Custom.ChatFromGroupID))
|
||||
} else if data.Custom.ChatFromID != 0 {
|
||||
sender, err = tc.main.Bridge.GetGhostByID(ctx, ids.MakeUserID(data.Custom.ChatFromID))
|
||||
} else if data.Custom.FromID != 0 {
|
||||
sender, err = tc.main.Bridge.GetGhostByID(ctx, ids.MakeUserID(data.Custom.FromID))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sender: %w", err)
|
||||
}
|
||||
if relatedPortal != nil && data.Custom.MessageID != 0 {
|
||||
messageID = ids.MakeMessageID(relatedPortal.PortalKey, data.Custom.MessageID)
|
||||
}
|
||||
notifs.DisplayNotification(ctx, &bridgev2.DirectNotificationData{
|
||||
Portal: relatedPortal,
|
||||
Sender: sender,
|
||||
Message: messageText,
|
||||
MessageID: messageID,
|
||||
|
||||
FormattedNotification: notificationText,
|
||||
FormattedTitle: notificationTitle,
|
||||
})
|
||||
}
|
||||
if FullSyncOnConnectBackground {
|
||||
tc.Connect(ctx)
|
||||
defer tc.Disconnect()
|
||||
// TODO is it possible to safely only sync one chat?
|
||||
select {
|
||||
case <-time.After(20 * time.Second):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TelegramConnector) ParsePushNotification(ctx context.Context, data json.RawMessage) (networkid.UserLoginID, any, error) {
|
||||
val := gjson.GetBytes(data, "p")
|
||||
if val.Type != gjson.String {
|
||||
return "", nil, fmt.Errorf("missing or invalid p field")
|
||||
}
|
||||
valBytes, err := base64.RawURLEncoding.DecodeString(val.Str)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to base64 decode p field: %w", err)
|
||||
}
|
||||
var em crypto.EncryptedMessage
|
||||
err = em.DecodeWithoutCopy(&bin.Buffer{Buf: valBytes})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to decode auth key and message ID: %w", err)
|
||||
}
|
||||
userIDs, err := tc.Bridge.DB.UserLogin.GetAllUserIDsWithLogins(ctx)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to get users with logins: %w", err)
|
||||
}
|
||||
var matchingAuthKey *crypto.AuthKey
|
||||
var userLoginID networkid.UserLoginID
|
||||
UserLoop:
|
||||
for _, userID := range userIDs {
|
||||
user, err := tc.Bridge.GetExistingUserByMXID(ctx, userID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to get user %s: %w", userID, err)
|
||||
}
|
||||
for _, login := range user.GetUserLogins() {
|
||||
key := login.Metadata.(*UserLoginMetadata).PushEncryptionKey
|
||||
if len(key) != 256 {
|
||||
continue
|
||||
}
|
||||
authKey := crypto.Key(key).WithID()
|
||||
if authKey.ID == em.AuthKeyID {
|
||||
matchingAuthKey = &authKey
|
||||
userLoginID = login.ID
|
||||
break UserLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchingAuthKey == nil {
|
||||
return "", nil, fmt.Errorf("no matching auth key found")
|
||||
}
|
||||
c := crypto.NewClientCipher(rand.Reader)
|
||||
plaintext, err := c.DecryptRaw(*matchingAuthKey, &em)
|
||||
if err != nil {
|
||||
return userLoginID, nil, fmt.Errorf("failed to decrypt payload: %w", err)
|
||||
} else if len(plaintext) < 4 {
|
||||
return userLoginID, nil, fmt.Errorf("decrypted payload too short (expected >4, got %d)", len(plaintext))
|
||||
}
|
||||
jsonLength := binary.LittleEndian.Uint32(plaintext[0:4])
|
||||
if len(plaintext) < int(jsonLength)+4 {
|
||||
return userLoginID, nil, fmt.Errorf("decrypted payload too short (expected 4+%d, got %d)", jsonLength, len(plaintext))
|
||||
}
|
||||
jsonData := plaintext[4 : jsonLength+4]
|
||||
var pmd PushNotificationData
|
||||
err = json.Unmarshal(jsonData, &pmd)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Debug().Str("raw_data", base64.StdEncoding.EncodeToString(plaintext)).Msg("Decrypted non-JSON push data")
|
||||
return userLoginID, nil, fmt.Errorf("failed to unmarshal decrypted payload: %w", err)
|
||||
}
|
||||
if pmd.Aps != nil {
|
||||
// APNs notifications don't have custom data nested in a separate key, they have it at the top level
|
||||
err = json.Unmarshal(jsonData, &pmd.Custom)
|
||||
if err != nil {
|
||||
return userLoginID, nil, fmt.Errorf("failed to unmarshal APNs data into custom field: %w", err)
|
||||
}
|
||||
}
|
||||
return userLoginID, &pmd, nil
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) RegisterPushNotifications(ctx context.Context, pushType bridgev2.PushType, token string) error {
|
||||
meta := tc.metadata
|
||||
if meta.PushEncryptionKey == nil {
|
||||
meta.PushEncryptionKey = random.Bytes(256)
|
||||
err := tc.userLogin.Save(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save push encryption key: %w", err)
|
||||
}
|
||||
}
|
||||
var tokenType int
|
||||
switch pushType {
|
||||
case bridgev2.PushTypeWeb:
|
||||
tokenType = 10
|
||||
case bridgev2.PushTypeFCM:
|
||||
tokenType = 2
|
||||
case bridgev2.PushTypeAPNs:
|
||||
tokenType = 1
|
||||
default:
|
||||
return fmt.Errorf("unsupported push type %s", pushType)
|
||||
}
|
||||
_, err := tc.client.API().AccountRegisterDevice(ctx, &tg.AccountRegisterDeviceRequest{
|
||||
NoMuted: true,
|
||||
TokenType: tokenType,
|
||||
Token: token,
|
||||
AppSandbox: PushAppSandbox,
|
||||
Secret: meta.PushEncryptionKey,
|
||||
OtherUIDs: nil, // TODO set properly
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) GetPushConfigs() *bridgev2.PushConfig {
|
||||
return &bridgev2.PushConfig{Native: true}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"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/emojis"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
func (tc *TelegramClient) computeReactionsList(ctx context.Context, peer tg.PeerClass, msgID int, msgReactions tg.MessageReactions) (reactions []tg.MessagePeerReaction, isFull bool, customEmojis map[networkid.EmojiID]emojis.EmojiInfo, err error) {
|
||||
log := zerolog.Ctx(ctx).With().Str("fn", "computeReactionsList").Logger()
|
||||
var totalCount int
|
||||
for _, r := range msgReactions.Results {
|
||||
totalCount += r.Count
|
||||
}
|
||||
|
||||
reactionsList := msgReactions.RecentReactions
|
||||
if totalCount > 0 && len(reactionsList) == 0 && !msgReactions.CanSeeList {
|
||||
// We don't know who reacted in a channel, so we can't bridge it properly either
|
||||
log.Trace().Msg("Can't see reaction list in channel")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO
|
||||
// if self.peer_type == "channel" and not self.megagroup:
|
||||
// # This should never happen with the previous if
|
||||
// self.log.warning(f"Can see reaction list in channel ({data!s})")
|
||||
// # return
|
||||
|
||||
if len(reactionsList) < totalCount {
|
||||
if user, ok := peer.(*tg.PeerUser); ok {
|
||||
reactionsList = splitDMReactionCounts(msgReactions.Results, user.UserID, tc.telegramUserID)
|
||||
} else if tc.metadata.IsBot {
|
||||
// Can't fetch exact reaction senders as a bot
|
||||
return
|
||||
|
||||
// TODO remove redundant peer roundtrip, just add a peer -> input peer helper
|
||||
} else if peer, _, err := tc.inputPeerForPortalID(ctx, tc.makePortalKeyFromPeer(peer, 0).ID); err != nil {
|
||||
return nil, false, nil, fmt.Errorf("failed to get input peer: %w", err)
|
||||
} else {
|
||||
// TODO should calls to this be limited?
|
||||
reactions, err := APICallWithUpdates(ctx, tc, func() (*tg.MessagesMessageReactionsList, error) {
|
||||
return tc.client.API().MessagesGetMessageReactionsList(ctx, &tg.MessagesGetMessageReactionsListRequest{
|
||||
Peer: peer, ID: msgID, Limit: 100,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, nil, fmt.Errorf("failed to get reactions list: %w", err)
|
||||
}
|
||||
reactionsList = reactions.Reactions
|
||||
}
|
||||
}
|
||||
|
||||
var customEmojiIDs []int64
|
||||
for _, reaction := range reactionsList {
|
||||
if e, ok := reaction.Reaction.(*tg.ReactionCustomEmoji); ok {
|
||||
customEmojiIDs = append(customEmojiIDs, e.DocumentID)
|
||||
} else if reaction.Reaction.TypeID() != tg.ReactionEmojiTypeID {
|
||||
return nil, false, nil, fmt.Errorf("unsupported reaction type %T", reaction.Reaction)
|
||||
}
|
||||
}
|
||||
|
||||
customEmojis, err = tc.transferEmojisToMatrix(ctx, customEmojiIDs)
|
||||
return reactionsList, len(reactionsList) == totalCount, customEmojis, err
|
||||
}
|
||||
|
||||
func computeEmojiAndID(reaction tg.ReactionClass, customEmojis map[networkid.EmojiID]emojis.EmojiInfo) (emojiID networkid.EmojiID, emoji string, err error) {
|
||||
if r, ok := reaction.(*tg.ReactionCustomEmoji); ok {
|
||||
emojiID = ids.MakeEmojiIDFromDocumentID(r.DocumentID)
|
||||
emoji = customEmojis[emojiID].Emoji
|
||||
if emoji == "" {
|
||||
emoji = string(customEmojis[emojiID].EmojiURI)
|
||||
}
|
||||
} else if r, ok := reaction.(*tg.ReactionEmoji); ok {
|
||||
emojiID = ids.MakeEmojiIDFromEmoticon(r.Emoticon)
|
||||
emoji = r.Emoticon
|
||||
} else {
|
||||
return "", "", fmt.Errorf("invalid reaction type %T", reaction)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) prepareReactionSync(ctx context.Context, peer tg.PeerClass, msgID int, reactions tg.MessageReactions) (*bridgev2.ReactionSyncData, error) {
|
||||
reactionsList, isFull, customEmojis, err := tc.computeReactionsList(ctx, peer, msgID, reactions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute reactions: %w", err)
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx)
|
||||
users := map[networkid.UserID]*bridgev2.ReactionSyncUser{}
|
||||
for _, reaction := range reactionsList {
|
||||
var userID networkid.UserID
|
||||
var eventSender bridgev2.EventSender
|
||||
switch senderPeer := reaction.PeerID.(type) {
|
||||
case *tg.PeerUser:
|
||||
userID = ids.MakeUserID(senderPeer.UserID)
|
||||
eventSender = tc.senderForUserID(senderPeer.UserID)
|
||||
case *tg.PeerChannel:
|
||||
userID = ids.MakeChannelUserID(senderPeer.ChannelID)
|
||||
eventSender = bridgev2.EventSender{
|
||||
Sender: userID,
|
||||
IsFromMe: reaction.My && tc.main.Bridge.Config.SplitPortals,
|
||||
}
|
||||
default:
|
||||
log.Debug().Type("peer_type", reaction.PeerID).Msg("Ignoring reaction from non-user peer")
|
||||
continue
|
||||
}
|
||||
reactionLimit, err := tc.getReactionLimit(ctx, userID)
|
||||
if err != nil {
|
||||
reactionLimit = 1
|
||||
log.Err(err).Str("id", string(userID)).Msg("failed to get reaction limit")
|
||||
}
|
||||
if _, ok := users[userID]; !ok {
|
||||
users[userID] = &bridgev2.ReactionSyncUser{HasAllReactions: isFull, MaxCount: reactionLimit}
|
||||
}
|
||||
|
||||
emojiID, emoji, err := computeEmojiAndID(reaction.Reaction, customEmojis)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to compute emoji and ID for reaction")
|
||||
continue
|
||||
}
|
||||
|
||||
users[userID].Reactions = append(users[userID].Reactions, &bridgev2.BackfillReaction{
|
||||
Timestamp: time.Unix(int64(reaction.Date), 0),
|
||||
Sender: eventSender,
|
||||
EmojiID: emojiID,
|
||||
Emoji: emoji,
|
||||
})
|
||||
}
|
||||
return &bridgev2.ReactionSyncData{Users: users, HasAllUsers: isFull}, nil
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) handleTelegramReactions(ctx context.Context, peer tg.PeerClass, topicID, msgID int, reactions tg.MessageReactions, source string) error {
|
||||
ctx = zerolog.Ctx(ctx).With().
|
||||
Str("handler", "handle_telegram_reactions").
|
||||
Str("sync_source", source).
|
||||
Int("message_id", msgID).
|
||||
Logger().WithContext(ctx)
|
||||
|
||||
data, err := tc.prepareReactionSync(ctx, peer, msgID, reactions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return resultToError(tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ReactionSync{
|
||||
EventMeta: simplevent.EventMeta{
|
||||
Type: bridgev2.RemoteEventReactionSync,
|
||||
LogContext: func(c zerolog.Context) zerolog.Context {
|
||||
return c.Int("message_id", msgID).Str("sync_source", source)
|
||||
},
|
||||
PortalKey: tc.makePortalKeyFromPeer(peer, topicID),
|
||||
},
|
||||
TargetMessage: ids.MakeMessageID(peer, msgID),
|
||||
Reactions: data,
|
||||
}))
|
||||
}
|
||||
|
||||
func splitDMReactionCounts(res []tg.ReactionCount, theirUserID, myUserID int64) (reactions []tg.MessagePeerReaction) {
|
||||
for _, item := range res {
|
||||
if item.Count == 2 || item.ChosenOrder > 0 {
|
||||
reactions = append(reactions, tg.MessagePeerReaction{
|
||||
Reaction: item.Reaction,
|
||||
PeerID: &tg.PeerUser{UserID: myUserID},
|
||||
})
|
||||
}
|
||||
|
||||
if item.Count == 2 {
|
||||
reactions = append(reactions, tg.MessagePeerReaction{
|
||||
Reaction: item.Reaction,
|
||||
PeerID: &tg.PeerUser{UserID: theirUserID},
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) getReactionLimit(ctx context.Context, sender networkid.UserID) (limit int, err error) {
|
||||
config, err := tc.getAppConfigCached(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ghost, err := tc.main.Bridge.GetGhostByID(ctx, sender)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ghost.Metadata.(*GhostMetadata).IsPremium {
|
||||
if maxReactions, ok := config["reactions_user_max_premium"].(float64); ok {
|
||||
return int(maxReactions), nil
|
||||
} else {
|
||||
return 3, nil
|
||||
}
|
||||
} else {
|
||||
if maxReactions, ok := config["reactions_user_max_default"].(float64); ok {
|
||||
return int(maxReactions), nil
|
||||
} else {
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) maybePollForReactions(ctx context.Context, portal *bridgev2.Portal) error {
|
||||
// Only poll for reactions in supergroups
|
||||
if tc.metadata.IsBot || portal == nil || !portal.Metadata.(*PortalMetadata).IsSuperGroup || portal.RoomType == database.RoomTypeSpace {
|
||||
return nil
|
||||
}
|
||||
|
||||
tc.prevReactionPollLock.Lock()
|
||||
prev, ok := tc.prevReactionPoll[portal.PortalKey]
|
||||
if ok && time.Since(prev) > 20*time.Second {
|
||||
ok = false
|
||||
tc.prevReactionPoll[portal.PortalKey] = time.Now()
|
||||
}
|
||||
tc.prevReactionPollLock.Unlock()
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
return tc.pollForReactions(ctx, portal.PortalKey)
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) pollForReactions(ctx context.Context, portalKey networkid.PortalKey) error {
|
||||
inputPeer, _, parseErr := tc.inputPeerForPortalID(ctx, portalKey.ID)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
log := zerolog.Ctx(ctx).With().
|
||||
Stringer("portal_key", portalKey).
|
||||
Str("action", "poll_for_reactions").
|
||||
Logger()
|
||||
|
||||
log.Debug().Msg("Polling reactions for recent messages")
|
||||
|
||||
messages, err := tc.main.Bridge.DB.Message.GetLastNInPortal(ctx, portalKey, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
messageIDs := make([]int, len(messages))
|
||||
for i, msg := range messages {
|
||||
_, messageIDs[i], err = ids.ParseMessageID(msg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
updates, err := APICallWithUpdates(ctx, tc, func() (*tg.Updates, error) {
|
||||
u, err := tc.client.API().MessagesGetMessagesReactions(ctx, &tg.MessagesGetMessagesReactionsRequest{
|
||||
Peer: inputPeer,
|
||||
ID: messageIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updates, ok := u.(*tg.Updates); ok {
|
||||
return updates, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("unexpected updates type %T", u)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get messages reactions: %w", err)
|
||||
}
|
||||
|
||||
for _, update := range updates.Updates {
|
||||
reaction, ok := update.(*tg.UpdateMessageReactions)
|
||||
if !ok {
|
||||
log.Warn().Type("update_type", update).Msg("Unexpected update type in get reactions response")
|
||||
continue
|
||||
}
|
||||
dbMsg, err := tc.main.Bridge.DB.Message.GetFirstPartByID(ctx, tc.loginID, ids.MakeMessageID(portalKey, reaction.MsgID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get message from database: %w", err)
|
||||
} else if dbMsg == nil {
|
||||
return fmt.Errorf("message not found in database: %w", err)
|
||||
}
|
||||
|
||||
data, err := tc.prepareReactionSync(ctx, reaction.Peer, reaction.MsgID, reaction.Reactions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res := tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ReactionSync{
|
||||
EventMeta: simplevent.EventMeta{
|
||||
Type: bridgev2.RemoteEventReactionSync,
|
||||
LogContext: func(c zerolog.Context) zerolog.Context {
|
||||
return c.Int("message_id", reaction.MsgID).Str("sync_source", "poll")
|
||||
},
|
||||
PortalKey: dbMsg.Room,
|
||||
},
|
||||
TargetMessage: dbMsg.ID,
|
||||
Reactions: data,
|
||||
})
|
||||
if err = resultToError(res); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.mau.fi/util/ptr"
|
||||
"maunium.net/go/mautrix/bridgev2"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/store"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/query/hasher"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
var (
|
||||
_ bridgev2.IdentifierResolvingNetworkAPI = (*TelegramClient)(nil)
|
||||
_ bridgev2.ContactListingNetworkAPI = (*TelegramClient)(nil)
|
||||
_ bridgev2.UserSearchingNetworkAPI = (*TelegramClient)(nil)
|
||||
_ bridgev2.GroupCreatingNetworkAPI = (*TelegramClient)(nil)
|
||||
)
|
||||
|
||||
func (tc *TelegramClient) resolveUser(ctx context.Context, user tg.UserClass) (*bridgev2.ResolveIdentifierResponse, error) {
|
||||
networkUserID := ids.MakeUserID(user.GetID())
|
||||
if ghost, err := tc.main.Bridge.GetGhostByID(ctx, networkUserID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get ghost: %w", err)
|
||||
} else if userInfo, err := tc.wrapUserInfo(ctx, user, ghost); err != nil {
|
||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||
} else {
|
||||
return tc.makeResolveIdentifierResponse(ghost, user, userInfo), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) makeResolveIdentifierResponse(ghost *bridgev2.Ghost, user tg.UserClass, info *bridgev2.UserInfo) *bridgev2.ResolveIdentifierResponse {
|
||||
return &bridgev2.ResolveIdentifierResponse{
|
||||
Ghost: ghost,
|
||||
UserID: ids.MakeUserID(user.GetID()),
|
||||
UserInfo: info,
|
||||
Chat: &bridgev2.CreateChatResponse{
|
||||
PortalKey: tc.makePortalKeyFromID(ids.PeerTypeUser, user.GetID(), 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) resolveUserID(ctx context.Context, userID int64) (resp *bridgev2.ResolveIdentifierResponse, err error) {
|
||||
_, err = tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeUser, userID)
|
||||
if errors.Is(err, store.ErrNoAccessHash) {
|
||||
username, usernameErr := tc.main.Store.Username.Get(ctx, ids.PeerTypeUser, userID)
|
||||
if usernameErr != nil {
|
||||
return nil, fmt.Errorf("failed to get username after missing access hash: %w", usernameErr)
|
||||
} else if username != "" {
|
||||
zerolog.Ctx(ctx).Debug().
|
||||
Str("target_username", username).
|
||||
Int64("target_user_id", userID).
|
||||
Msg("Access hash not found for user ID, trying to look up username")
|
||||
return tc.resolveUsername(ctx, username, userID)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %w", bridgev2.ErrResolveIdentifierTryNext, err)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access hash from store: %w", err)
|
||||
}
|
||||
networkUserID := ids.MakeUserID(userID)
|
||||
resp = &bridgev2.ResolveIdentifierResponse{
|
||||
UserID: networkUserID,
|
||||
Chat: &bridgev2.CreateChatResponse{
|
||||
PortalKey: tc.makePortalKeyFromID(ids.PeerTypeUser, userID, 0),
|
||||
},
|
||||
}
|
||||
resp.Ghost, err = tc.main.Bridge.GetExistingGhostByID(ctx, networkUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ghost: %w", err)
|
||||
} else if resp.Ghost == nil || resp.Ghost.Name == "" {
|
||||
// Try to fetch the user from Telegram
|
||||
if user, err := tc.getSingleUser(ctx, userID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get user with ID %d: %w", userID, err)
|
||||
} else if user.TypeID() != tg.UserTypeID {
|
||||
return nil, fmt.Errorf("unexpected user type: %T", user)
|
||||
} else if userInfo, err := tc.updateGhost(ctx, userID, user.(*tg.User)); err != nil {
|
||||
return nil, fmt.Errorf("failed to update ghost: %w", err)
|
||||
} else {
|
||||
if resp.Ghost == nil {
|
||||
resp.Ghost, _ = tc.main.Bridge.GetExistingGhostByID(ctx, networkUserID)
|
||||
}
|
||||
return tc.makeResolveIdentifierResponse(resp.Ghost, user, userInfo), nil
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) resolveUsername(ctx context.Context, username string, expectedID int64) (*bridgev2.ResolveIdentifierResponse, error) {
|
||||
resolved, err := APICallWithUpdates(ctx, tc, func() (*tg.ContactsResolvedPeer, error) {
|
||||
return tc.client.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
|
||||
Username: username,
|
||||
})
|
||||
})
|
||||
if tg.IsUsernameNotOccupied(err) {
|
||||
if expectedID != 0 {
|
||||
err = tc.main.Store.Username.Delete(ctx, username)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Warn().Err(err).Str("username", username).
|
||||
Msg("Failed to delete stale username mapping")
|
||||
}
|
||||
return nil, fmt.Errorf("%w: resolving %s didn't return a result (wanted %d)", bridgev2.ErrResolveIdentifierTryNext, username, expectedID)
|
||||
}
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve username: %w", err)
|
||||
}
|
||||
peer, ok := resolved.GetPeer().(*tg.PeerUser)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected peer type: %T", resolved.GetPeer())
|
||||
}
|
||||
if expectedID != 0 && peer.GetUserID() != expectedID {
|
||||
return nil, fmt.Errorf("%w: resolving %s returned %d instead of %d", bridgev2.ErrResolveIdentifierTryNext, username, peer.GetUserID(), expectedID)
|
||||
}
|
||||
for _, user := range resolved.GetUsers() {
|
||||
if user.GetID() == peer.GetUserID() {
|
||||
return tc.resolveUser(ctx, user)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("peer user not found in contact resolved response")
|
||||
}
|
||||
|
||||
// Parses user links or usernames with or without the @ sign in front of the username.
|
||||
// This verifies the following restrictions:
|
||||
// - Usernames must be at least 5 characters long
|
||||
// - Usernames must be at most 32 characters long
|
||||
// - Usernames must start with a letter
|
||||
// - Usernames must contain only letters, numbers, and underscores
|
||||
// - Usernames cannot end with an underscore
|
||||
// TODO some usernames are shorter, figure out actual limits
|
||||
// (some bots like @pic and @gif have 3 characters, fragment might allow 4 characters)
|
||||
var usernameRe = regexp.MustCompile(`^(?:(?:https?://)?t(?:elegram)?\.(?:me|dog)/|tg:/{0,2}resolve\?domain=|@)?([a-zA-Z]\w{3,30}[a-zA-Z\d])$`)
|
||||
|
||||
func (tc *TelegramClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error) {
|
||||
log := zerolog.Ctx(ctx).With().Str("identifier", identifier).Logger()
|
||||
log.Debug().Msg("Resolving identifier")
|
||||
|
||||
if len(identifier) == 0 {
|
||||
return nil, fmt.Errorf("empty identifier")
|
||||
}
|
||||
|
||||
if identifier[0] == '+' {
|
||||
normalized := strings.TrimPrefix(identifier, "+")
|
||||
if userID, err := tc.main.Store.PhoneNumber.GetUserID(ctx, normalized); err != nil {
|
||||
return nil, fmt.Errorf("failed to get user ID by phone number: %w", err)
|
||||
} else if userID == 0 {
|
||||
log.Info().Msg("Phone number not found in database")
|
||||
return nil, nil
|
||||
} else {
|
||||
return tc.resolveUserID(ctx, userID)
|
||||
}
|
||||
} else if userID, err := strconv.ParseInt(identifier, 10, 64); err == nil && userID > 0 {
|
||||
// This is an integer, try and parse it as a Telegram User ID
|
||||
return tc.resolveUserID(ctx, userID)
|
||||
} else if match := usernameRe.FindStringSubmatch(identifier); match != nil && !strings.Contains(identifier, "__") {
|
||||
// This is a username
|
||||
entityType, userID, err := tc.main.Store.Username.GetEntityID(ctx, match[1])
|
||||
if entityType == ids.PeerTypeUser && (err == nil || userID != 0) {
|
||||
// We know this username.
|
||||
resp, err := tc.resolveUserID(ctx, userID)
|
||||
if err == nil || !errors.Is(err, store.ErrNoAccessHash) {
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
return tc.resolveUsername(ctx, match[1], 0)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid identifier: %q (must be a phone number, username, or Telegram user ID)", identifier)
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) SearchUsers(ctx context.Context, query string) (resp []*bridgev2.ResolveIdentifierResponse, err error) {
|
||||
contactsFound, err := APICallWithUpdates(ctx, tc, func() (*tg.ContactsFound, error) {
|
||||
return tc.client.API().ContactsSearch(ctx, &tg.ContactsSearchRequest{Q: query})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := map[int64]tg.UserClass{}
|
||||
for _, user := range contactsFound.GetUsers() {
|
||||
users[user.GetID()] = user
|
||||
}
|
||||
|
||||
addResult := func(p tg.PeerClass) error {
|
||||
if peer, ok := p.(*tg.PeerUser); !ok {
|
||||
return nil
|
||||
} else if user, ok := users[peer.GetUserID()]; ok {
|
||||
if r, err := tc.resolveUser(ctx, user); err != nil {
|
||||
return err
|
||||
} else {
|
||||
resp = append(resp, r)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("peer user not found in contact search response")
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range contactsFound.MyResults {
|
||||
if err := addResult(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, p := range contactsFound.Results {
|
||||
if err := addResult(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (tc *TelegramClient) GetContactList(ctx context.Context) (resp []*bridgev2.ResolveIdentifierResponse, err error) {
|
||||
tc.contactsLock.Lock()
|
||||
defer tc.contactsLock.Unlock()
|
||||
var contacts *tg.ContactsContacts
|
||||
if time.Since(tc.lastContactReq) > 10*time.Minute {
|
||||
contacts, err = APICallWithOnlyUserUpdates(ctx, tc, func() (*tg.ContactsContacts, error) {
|
||||
c, err := tc.client.API().ContactsGetContacts(ctx, tc.cachedContactsHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch typedResp := c.(type) {
|
||||
case *tg.ContactsContacts:
|
||||
tc.cachedContacts = typedResp
|
||||
var h hasher.Hasher
|
||||
for _, contact := range tc.cachedContacts.Contacts {
|
||||
h.Update(uint32(contact.UserID))
|
||||
}
|
||||
tc.cachedContactsHash = h.Sum()
|
||||
case *tg.ContactsContactsNotModified:
|
||||
// No changes
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected contacts type: %T", c)
|
||||
}
|
||||
return tc.cachedContacts, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc.lastContactReq = time.Now()
|
||||
} else {
|
||||
contacts = tc.cachedContacts
|
||||
}
|
||||
users := map[int64]tg.UserClass{}
|
||||
for _, user := range contacts.GetUsers() {
|
||||
users[user.GetID()] = user
|
||||
}
|
||||
|
||||
for _, contact := range contacts.Contacts {
|
||||
if user, ok := users[contact.UserID]; ok {
|
||||
if r, err := tc.resolveUser(ctx, user); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
resp = append(resp, r)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("contact user not found in contact list response")
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TODO support channels
|
||||
func (tc *TelegramClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) {
|
||||
req := tg.MessagesCreateChatRequest{
|
||||
Title: ptr.Val(params.Name).Name,
|
||||
}
|
||||
for _, networkUserID := range params.Participants {
|
||||
if peerType, userID, err := ids.ParseUserID(networkUserID); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user ID: %w", err)
|
||||
} else if peerType != ids.PeerTypeUser {
|
||||
return nil, fmt.Errorf("unexpected peer type: %s", peerType)
|
||||
} else if inputUser, err := tc.getInputUser(ctx, userID); err != nil {
|
||||
return nil, fmt.Errorf("failed to get input user: %w", err)
|
||||
} else {
|
||||
req.Users = append(req.Users, inputUser)
|
||||
}
|
||||
}
|
||||
invitedUsers, err := tc.client.API().MessagesCreateChat(ctx, &req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create chat: %w", err)
|
||||
}
|
||||
invited, ok := invitedUsers.Updates.(interface {
|
||||
GetChats() (value []tg.ChatClass)
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response type: %T", invitedUsers.Updates)
|
||||
}
|
||||
|
||||
// TODO notify about users that couldn't be invited
|
||||
|
||||
if chats := invited.GetChats(); len(chats) != 1 {
|
||||
return nil, fmt.Errorf("unexpected number of chats: %d", len(chats))
|
||||
} else if chat, ok := chats[0].(*tg.Chat); !ok {
|
||||
return nil, fmt.Errorf("unexpected chat type: %T", chats[0])
|
||||
} else {
|
||||
portalKey := tc.makePortalKeyFromID(ids.PeerTypeChat, chat.ID, 0)
|
||||
if params.RoomID != "" {
|
||||
portal, err := tc.main.Bridge.GetPortalByKey(ctx, portalKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = portal.UpdateMatrixRoomID(ctx, params.RoomID, bridgev2.UpdateMatrixRoomIDParams{
|
||||
SyncDBMetadata: func() {
|
||||
portal.Name = req.Title
|
||||
portal.NameSet = true
|
||||
},
|
||||
OverwriteOldPortal: true,
|
||||
TombstoneOldRoom: true,
|
||||
DeleteOldRoom: true,
|
||||
ChatInfoSource: tc.userLogin,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &bridgev2.CreateChatResponse{
|
||||
PortalKey: portalKey,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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 store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
"go.mau.fi/util/exsync"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/store/upgrades"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
*dbutil.Database
|
||||
|
||||
TelegramFile *TelegramFileQuery
|
||||
Username *UsernameQuery
|
||||
PhoneNumber *PhoneNumberQuery
|
||||
Topic *TopicQuery
|
||||
}
|
||||
|
||||
func NewStore(db *dbutil.Database, log dbutil.DatabaseLogger) *Container {
|
||||
return &Container{
|
||||
Database: db.Child("telegram_version", upgrades.Table, log),
|
||||
|
||||
TelegramFile: &TelegramFileQuery{dbutil.MakeQueryHelper(db, newTelegramFile)},
|
||||
Username: &UsernameQuery{db},
|
||||
PhoneNumber: &PhoneNumberQuery{db},
|
||||
Topic: &TopicQuery{db: db, existingTopics: exsync.NewSet[topicKey]()},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) Upgrade(ctx context.Context) error {
|
||||
return c.Database.Upgrade(ctx)
|
||||
}
|
||||
|
||||
func (c *Container) GetScopedStore(telegramUserID int64) *ScopedStore {
|
||||
return &ScopedStore{c.Database, telegramUserID}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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 store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
)
|
||||
|
||||
type PhoneNumberQuery struct {
|
||||
db *dbutil.Database
|
||||
}
|
||||
|
||||
const (
|
||||
getEntityIDForPhoneNumber = "SELECT entity_id FROM telegram_phone_number WHERE phone_number=$1"
|
||||
setPhoneNumberQuery = `
|
||||
INSERT INTO telegram_phone_number (phone_number, entity_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (phone_number) DO UPDATE SET entity_id=excluded.entity_id
|
||||
`
|
||||
clearPhoneNumberQuery = "DELETE FROM telegram_phone_number WHERE entity_id=$1"
|
||||
)
|
||||
|
||||
func (s *PhoneNumberQuery) GetUserID(ctx context.Context, phoneNumber string) (userID int64, err error) {
|
||||
err = s.db.QueryRow(ctx, getEntityIDForPhoneNumber, phoneNumber).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *PhoneNumberQuery) Set(ctx context.Context, userID int64, phoneNumber string) (err error) {
|
||||
if phoneNumber == "" {
|
||||
_, err = s.db.Exec(ctx, clearPhoneNumberQuery, userID)
|
||||
} else {
|
||||
_, err = s.db.Exec(ctx, setPhoneNumberQuery, phoneNumber, userID)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// 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 store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/updates"
|
||||
)
|
||||
|
||||
// ScopedStore is a wrapper around a database that implements
|
||||
// [session.Storage] scoped to a specific Telegram user ID.
|
||||
type ScopedStore struct {
|
||||
db *dbutil.Database
|
||||
telegramUserID int64
|
||||
}
|
||||
|
||||
const (
|
||||
// State Storage Queries
|
||||
allChannelsQuery = "SELECT channel_id, pts FROM telegram_channel_state WHERE user_id=$1"
|
||||
getChannelPtsQuery = "SELECT pts FROM telegram_channel_state WHERE user_id=$1 AND channel_id=$2"
|
||||
setChannelPtsQuery = `
|
||||
INSERT INTO telegram_channel_state (user_id, channel_id, pts)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, channel_id) DO UPDATE SET pts=excluded.pts
|
||||
`
|
||||
getStateQuery = "SELECT pts, qts, date, seq from telegram_user_state WHERE user_id=$1"
|
||||
setStateQuery = `
|
||||
INSERT INTO telegram_user_state (user_id, pts, qts, date, seq)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
pts=excluded.pts,
|
||||
qts=excluded.qts,
|
||||
date=excluded.date,
|
||||
seq=excluded.seq
|
||||
`
|
||||
setPtsQuery = "UPDATE telegram_user_state SET pts=$1 WHERE user_id=$2"
|
||||
setQtsQuery = "UPDATE telegram_user_state SET qts=$1 WHERE user_id=$2"
|
||||
setDateQuery = "UPDATE telegram_user_state SET date=$1 WHERE user_id=$2"
|
||||
setSeqQuery = "UPDATE telegram_user_state SET seq=$1 WHERE user_id=$2"
|
||||
setDateSeqQuery = "UPDATE telegram_user_state SET date=$1, seq=$2 WHERE user_id=$3"
|
||||
|
||||
deleteChannelStateForUserQuery = "DELETE FROM telegram_channel_state WHERE user_id=$1"
|
||||
deleteUserStateForUserQuery = "DELETE FROM telegram_user_state WHERE user_id=$1"
|
||||
|
||||
getAccessHashQuery = "SELECT access_hash FROM telegram_access_hash WHERE user_id=$1 AND entity_type=$2 AND entity_id=$3"
|
||||
setAccessHashQuery = `
|
||||
INSERT INTO telegram_access_hash (user_id, entity_type, entity_id, access_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, entity_type, entity_id) DO UPDATE SET access_hash=excluded.access_hash
|
||||
`
|
||||
deleteAccessHashesForUserQuery = "DELETE FROM telegram_access_hash WHERE user_id=$1"
|
||||
)
|
||||
|
||||
var _ updates.StateStorage = (*ScopedStore)(nil)
|
||||
|
||||
type channelIDPtsTuple struct {
|
||||
ChannelID int64
|
||||
Pts int
|
||||
}
|
||||
|
||||
var ciptScanner = dbutil.ConvertRowFn[channelIDPtsTuple](func(row dbutil.Scannable) (cipt channelIDPtsTuple, err error) {
|
||||
err = row.Scan(&cipt.ChannelID, &cipt.Pts)
|
||||
return
|
||||
})
|
||||
|
||||
func (s *ScopedStore) ForEachChannels(ctx context.Context, userID int64, f func(ctx context.Context, channelID int64, pts int) error) error {
|
||||
s.assertUserIDMatches(userID)
|
||||
items, err := ciptScanner.NewRowIter(s.db.Query(ctx, allChannelsQuery, userID)).AsList()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
err = f(ctx, item.ChannelID, item.Pts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("iteration error for channel %d: %w", item.ChannelID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScopedStore) GetChannelPts(ctx context.Context, userID int64, channelID int64) (pts int, found bool, err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
err = s.db.QueryRow(ctx, getChannelPtsQuery, userID, channelID).Scan(&pts)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return pts, err == nil, err
|
||||
}
|
||||
|
||||
func (s *ScopedStore) SetChannelPts(ctx context.Context, userID int64, channelID int64, pts int) (err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
_, err = s.db.Exec(ctx, setChannelPtsQuery, userID, channelID, pts)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) GetState(ctx context.Context, userID int64) (state updates.State, found bool, err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
err = s.db.QueryRow(ctx, getStateQuery, userID).Scan(&state.Pts, &state.Qts, &state.Date, &state.Seq)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return state, false, nil
|
||||
}
|
||||
return state, err == nil, err
|
||||
}
|
||||
|
||||
func (s *ScopedStore) SetState(ctx context.Context, userID int64, state updates.State) (err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
_, err = s.db.Exec(ctx, setStateQuery, userID, state.Pts, state.Qts, state.Date, state.Seq)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) SetPts(ctx context.Context, userID int64, pts int) (err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
_, err = s.db.Exec(ctx, setPtsQuery, userID, pts)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) SetQts(ctx context.Context, userID int64, qts int) (err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
_, err = s.db.Exec(ctx, setQtsQuery, userID, qts)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) SetSeq(ctx context.Context, userID int64, seq int) (err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
_, err = s.db.Exec(ctx, setSeqQuery, userID, seq)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) SetDate(ctx context.Context, userID int64, date int) (err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
_, err = s.db.Exec(ctx, setDateQuery, userID, date)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) SetDateSeq(ctx context.Context, userID int64, date int, seq int) (err error) {
|
||||
s.assertUserIDMatches(userID)
|
||||
_, err = s.db.Exec(ctx, setDateSeqQuery, userID, date, seq)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) DeleteUserState(ctx context.Context) (err error) {
|
||||
_, err = s.db.Exec(ctx, deleteUserStateForUserQuery, s.telegramUserID)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) DeleteChannelStateForUser(ctx context.Context) (err error) {
|
||||
_, err = s.db.Exec(ctx, deleteChannelStateForUserQuery, s.telegramUserID)
|
||||
return
|
||||
}
|
||||
|
||||
var _ updates.AccessHasher = (*ScopedStore)(nil)
|
||||
|
||||
// Deprecated: only for interface, don't use directly. Use [GetAccessHash]
|
||||
// instead.
|
||||
func (s *ScopedStore) GetChannelAccessHash(ctx context.Context, forUserID, channelID int64) (int64, bool, error) {
|
||||
s.assertUserIDMatches(forUserID)
|
||||
accessHash, err := s.GetAccessHash(ctx, ids.PeerTypeChannel, channelID)
|
||||
if errors.Is(err, ErrNoAccessHash) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return accessHash, true, err
|
||||
}
|
||||
|
||||
// Deprecated: only for interface, don't use directly. Use [SetAccessHash]
|
||||
// instead.
|
||||
func (s *ScopedStore) SetChannelAccessHash(ctx context.Context, forUserID, channelID, accessHash int64) (err error) {
|
||||
s.assertUserIDMatches(forUserID)
|
||||
return s.SetAccessHash(ctx, ids.PeerTypeChannel, channelID, accessHash)
|
||||
}
|
||||
|
||||
// Deprecated: only for interface, don't use directly. Use [GetAccessHash]
|
||||
// instead.
|
||||
func (s *ScopedStore) GetUserAccessHash(ctx context.Context, forUserID int64, userID int64) (int64, bool, error) {
|
||||
s.assertUserIDMatches(forUserID)
|
||||
accessHash, err := s.GetAccessHash(ctx, ids.PeerTypeUser, userID)
|
||||
if errors.Is(err, ErrNoAccessHash) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return accessHash, true, err
|
||||
}
|
||||
|
||||
// Deprecated: only for interface, don't use directly. Use [SetAccessHash]
|
||||
// instead.
|
||||
func (s *ScopedStore) SetUserAccessHash(ctx context.Context, forUserID int64, userID int64, accessHash int64) error {
|
||||
s.assertUserIDMatches(forUserID)
|
||||
return s.SetAccessHash(ctx, ids.PeerTypeUser, userID, accessHash)
|
||||
}
|
||||
|
||||
var ErrNoAccessHash = errors.New("access hash not found")
|
||||
|
||||
func (s *ScopedStore) GetAccessHash(ctx context.Context, entityType ids.PeerType, entityID int64) (accessHash int64, err error) {
|
||||
err = s.db.QueryRow(ctx, getAccessHashQuery, s.telegramUserID, entityType, entityID).Scan(&accessHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fmt.Errorf("%w for %d", ErrNoAccessHash, entityID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) SetAccessHash(ctx context.Context, entityType ids.PeerType, entityID, accessHash int64) (err error) {
|
||||
_, err = s.db.Exec(ctx, setAccessHashQuery, s.telegramUserID, entityType, entityID, accessHash)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ScopedStore) DeleteAccessHashesForUser(ctx context.Context) (err error) {
|
||||
_, err = s.db.Exec(ctx, deleteAccessHashesForUserQuery, s.telegramUserID)
|
||||
return
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
|
||||
func (s *ScopedStore) assertUserIDMatches(userID int64) {
|
||||
if s.telegramUserID != userID {
|
||||
panic(fmt.Sprintf("scoped store for %d function called with user ID %d", s.telegramUserID, userID))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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 store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
insertTelegramFileQuery = `
|
||||
INSERT INTO telegram_file (id, mxc, mime_type, size, width, height, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`
|
||||
getTelegramFileSelect = "SELECT id, mxc, mime_type, size, width, height, timestamp FROM telegram_file"
|
||||
getTelegramFileByLocationIDQuery = getTelegramFileSelect + " WHERE id=$1"
|
||||
getTelegramFileByMXCQuery = getTelegramFileSelect + " WHERE mxc=$1 ORDER BY timestamp DESC LIMIT 1"
|
||||
)
|
||||
|
||||
type TelegramFileQuery struct {
|
||||
*dbutil.QueryHelper[*TelegramFile]
|
||||
}
|
||||
|
||||
type TelegramFileLocationID string
|
||||
|
||||
type TelegramFile struct {
|
||||
LocationID TelegramFileLocationID
|
||||
MXC id.ContentURIString
|
||||
MIMEType string
|
||||
Size int
|
||||
Width int
|
||||
Height int
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
var _ dbutil.DataStruct[*TelegramFile] = (*TelegramFile)(nil)
|
||||
|
||||
func newTelegramFile(qh *dbutil.QueryHelper[*TelegramFile]) *TelegramFile {
|
||||
return &TelegramFile{}
|
||||
}
|
||||
|
||||
func (fq *TelegramFileQuery) GetByLocationID(ctx context.Context, locationID TelegramFileLocationID) (*TelegramFile, error) {
|
||||
return fq.QueryOne(ctx, getTelegramFileByLocationIDQuery, locationID)
|
||||
}
|
||||
|
||||
func (fq *TelegramFileQuery) GetByMXC(ctx context.Context, mxc id.ContentURIString) (*TelegramFile, error) {
|
||||
return fq.QueryOne(ctx, getTelegramFileByMXCQuery, mxc)
|
||||
}
|
||||
|
||||
func (fq *TelegramFileQuery) Insert(ctx context.Context, f *TelegramFile) error {
|
||||
return fq.Exec(ctx, insertTelegramFileQuery, f.sqlVariables()...)
|
||||
}
|
||||
|
||||
func (f *TelegramFile) sqlVariables() []any {
|
||||
return []any{
|
||||
f.LocationID,
|
||||
f.MXC,
|
||||
dbutil.StrPtr(f.MIMEType),
|
||||
dbutil.NumPtr(f.Size),
|
||||
dbutil.NumPtr(f.Width),
|
||||
dbutil.NumPtr(f.Height),
|
||||
dbutil.ConvertedPtr(f.Timestamp, time.Time.Unix),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *TelegramFile) Scan(row dbutil.Scannable) (*TelegramFile, error) {
|
||||
var mime sql.NullString
|
||||
var size, width, height, timestamp sql.NullInt64
|
||||
err := row.Scan(&f.LocationID, &f.MXC, &mime, &size, &width, &height, ×tamp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.MIMEType = mime.String
|
||||
f.Size = int(size.Int64)
|
||||
f.Width = int(width.Int64)
|
||||
f.Height = int(height.Int64)
|
||||
if timestamp.Int64 > 0 {
|
||||
f.Timestamp = time.Unix(timestamp.Int64, 0)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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 store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
"go.mau.fi/util/exsync"
|
||||
)
|
||||
|
||||
type topicKey struct {
|
||||
ChannelID int64
|
||||
TopicID int
|
||||
}
|
||||
|
||||
type TopicQuery struct {
|
||||
db *dbutil.Database
|
||||
existingTopics *exsync.Set[topicKey]
|
||||
}
|
||||
|
||||
const (
|
||||
getAllTopicsQuery = `SELECT topic_id FROM telegram_topic WHERE channel_id=$1`
|
||||
addTopicQuery = `INSERT INTO telegram_topic (channel_id, topic_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`
|
||||
deleteTopicQuery = `DELETE FROM telegram_topic WHERE channel_id=$1 AND topic_id=$2`
|
||||
)
|
||||
|
||||
func (s *TopicQuery) Add(ctx context.Context, channelID int64, topicID int) (err error) {
|
||||
if channelID == 0 {
|
||||
return nil
|
||||
}
|
||||
if s.existingTopics.Add(topicKey{ChannelID: channelID, TopicID: topicID}) {
|
||||
_, err = s.db.Exec(ctx, addTopicQuery, channelID, topicID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *TopicQuery) Delete(ctx context.Context, channelID int64, topicID int) (err error) {
|
||||
s.existingTopics.Remove(topicKey{ChannelID: channelID, TopicID: topicID})
|
||||
_, err = s.db.Exec(ctx, deleteTopicQuery, channelID, topicID)
|
||||
return
|
||||
}
|
||||
|
||||
var intScanner = dbutil.ConvertRowFn[int](dbutil.ScanSingleColumn[int])
|
||||
|
||||
func (s *TopicQuery) GetAll(ctx context.Context, channelID int64) (topics []int, err error) {
|
||||
return intScanner.NewRowIter(s.db.Query(ctx, getAllTopicsQuery, channelID)).AsList()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
-- v0 -> v8 (compatible with v2+): Latest revision
|
||||
|
||||
CREATE TABLE telegram_user_state (
|
||||
user_id BIGINT NOT NULL PRIMARY KEY,
|
||||
pts BIGINT NOT NULL,
|
||||
qts BIGINT NOT NULL,
|
||||
date BIGINT NOT NULL,
|
||||
seq BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE telegram_channel_state (
|
||||
user_id BIGINT NOT NULL,
|
||||
channel_id BIGINT NOT NULL,
|
||||
pts BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (user_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE TABLE telegram_access_hash (
|
||||
user_id BIGINT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
access_hash BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (user_id, entity_type, entity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE telegram_username (
|
||||
username TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (username)
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_username_entity_idx ON telegram_username (entity_id);
|
||||
CREATE INDEX telegram_username_username_idx ON telegram_username (LOWER(username));
|
||||
|
||||
CREATE TABLE telegram_phone_number (
|
||||
phone_number TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (phone_number)
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_phone_number_entity_idx ON telegram_phone_number (entity_id);
|
||||
|
||||
CREATE TABLE telegram_file (
|
||||
id TEXT PRIMARY KEY,
|
||||
mxc TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
size BIGINT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
timestamp BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_file_mxc_idx ON telegram_file (mxc);
|
||||
|
||||
CREATE TABLE telegram_topic (
|
||||
channel_id BIGINT NOT NULL,
|
||||
topic_id BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (channel_id, topic_id)
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- v2: Separate users and channels into separate namespaces
|
||||
|
||||
ALTER TABLE telegram_access_hash RENAME TO telegram_access_hash_old;
|
||||
ALTER TABLE telegram_username RENAME TO telegram_username_old;
|
||||
|
||||
CREATE TABLE telegram_access_hash (
|
||||
user_id BIGINT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
access_hash BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (user_id, entity_type, entity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE telegram_username (
|
||||
username TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (username)
|
||||
);
|
||||
|
||||
INSERT INTO telegram_access_hash (user_id, entity_type, entity_id, access_hash)
|
||||
SELECT user_id, 'user', entity_id, access_hash
|
||||
FROM telegram_access_hash_old;
|
||||
|
||||
INSERT INTO telegram_access_hash (user_id, entity_type, entity_id, access_hash)
|
||||
SELECT user_id, 'channel', entity_id, access_hash
|
||||
FROM telegram_access_hash_old;
|
||||
|
||||
INSERT INTO telegram_username (username, entity_type, entity_id)
|
||||
SELECT username, 'user', entity_id
|
||||
FROM telegram_username_old;
|
||||
|
||||
DROP TABLE telegram_access_hash_old;
|
||||
DROP table telegram_username_old;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user