Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2355fcb6ac |
Generated
+20
-661
@@ -841,16 +841,6 @@ dependencies = [
|
||||
"futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -954,28 +944,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
@@ -1022,19 +997,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
"num-traits",
|
||||
"rand_core 0.10.1",
|
||||
"serdect",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -1057,16 +1019,6 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-primes"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
|
||||
dependencies = [
|
||||
"crypto-bigint 0.7.5",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
@@ -1322,37 +1274,6 @@ version = "3.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1644,7 +1565,7 @@ dependencies = [
|
||||
"ed25519 3.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"serde",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.11.0",
|
||||
"signature 3.0.0",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -1675,7 +1596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"crypto-bigint 0.5.5",
|
||||
"crypto-bigint",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
@@ -1709,7 +1630,7 @@ version = "3.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1892,13 +1813,12 @@ checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2181,22 +2101,6 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glass_pumpkin"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ff9ef01d1ba41822349a2a9970e706dc6035637957cdddecf32482a8a90e86e"
|
||||
dependencies = [
|
||||
"crypto-bigint 0.7.5",
|
||||
"crypto-primes",
|
||||
"getrandom 0.4.3",
|
||||
"num-bigint 0.5.1",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"rand_core 0.10.1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob-match"
|
||||
version = "0.2.1"
|
||||
@@ -2215,121 +2119,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grammers-client"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08072ddb6eeaca38870a0401d7a25ade7a541a5a3b81ada6877b981fe44606f5"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"grammers-crypto",
|
||||
"grammers-mtsender",
|
||||
"grammers-session",
|
||||
"grammers-tl-types",
|
||||
"jiff",
|
||||
"md5",
|
||||
"mime_guess",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grammers-crypto"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0154344dab25d8365408df205e4440b432cae02ef9dae2df478434be77c7075"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"ctr 0.10.1",
|
||||
"getrandom 0.4.3",
|
||||
"glass_pumpkin",
|
||||
"hmac 0.13.0",
|
||||
"num-bigint 0.5.1",
|
||||
"num-traits",
|
||||
"pbkdf2 0.13.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grammers-mtproto"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecead7953adb1c9ad6a2bdcea9f9931414be54246bdb4c722e990f7ade1193d3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"grammers-crypto",
|
||||
"grammers-tl-types",
|
||||
"num-bigint 0.5.1",
|
||||
"sha1 0.11.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grammers-mtsender"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b009d7d0f571cabac15e1ac7f588fefb764f23aba13bf8b9063a0a32a036075d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"grammers-crypto",
|
||||
"grammers-mtproto",
|
||||
"grammers-session",
|
||||
"grammers-tl-types",
|
||||
"hickory-resolver",
|
||||
"locate-locale",
|
||||
"os_info",
|
||||
"tokio",
|
||||
"tokio-socks",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grammers-session"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f34260cb63ecf5901e735a5f088ba6cb837d954649c223d5a53e7e980468ae0"
|
||||
dependencies = [
|
||||
"grammers-tl-types",
|
||||
"serde",
|
||||
"serde_with",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grammers-tl-gen"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf49f6769cffae7ea376669f3073c70eeb16ccc38f00ff019394069f3d401a4c"
|
||||
dependencies = [
|
||||
"grammers-tl-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grammers-tl-parser"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "646fc5eeb27461ffcc94fcf9fda3ffd9b402a20250447ae4a65c39c1a905e381"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grammers-tl-types"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "069e64c424bde5764391bc72f07085a12177d7053c8a0da355425527fd7c221f"
|
||||
dependencies = [
|
||||
"grammers-tl-gen",
|
||||
"grammers-tl-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -2421,7 +2210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc"
|
||||
dependencies = [
|
||||
"hax-lib-macros",
|
||||
"num-bigint 0.4.8",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
@@ -2475,76 +2264,6 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0"
|
||||
|
||||
[[package]]
|
||||
name = "hickory-net"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cfg-if",
|
||||
"data-encoding",
|
||||
"futures-channel",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hickory-proto",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"rand 0.10.2",
|
||||
"thiserror 2.0.21",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-proto"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"once_cell",
|
||||
"prefix-trie",
|
||||
"rand 0.10.2",
|
||||
"ring",
|
||||
"thiserror 2.0.21",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-resolver"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"hickory-net",
|
||||
"hickory-proto",
|
||||
"ipconfig",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"moka",
|
||||
"ndk-context",
|
||||
"once_cell",
|
||||
"parking_lot 0.12.5",
|
||||
"rand 0.10.2",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"system-configuration",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
@@ -2599,7 +2318,7 @@ dependencies = [
|
||||
"hkdf 0.13.0",
|
||||
"hybrid-array",
|
||||
"rand_core 0.10.1",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"x25519-dalek 3.0.0",
|
||||
"zeroize",
|
||||
@@ -3083,27 +2802,11 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iprange"
|
||||
@@ -3173,60 +2876,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -3683,15 +3332,6 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "locate-locale"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2835eaaed39a92511442aff277d4dca3d7674ca058df3bc45170661c2ccb4619"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -3845,7 +3485,7 @@ dependencies = [
|
||||
"ruma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
@@ -3917,17 +3557,11 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -3987,16 +3621,6 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
@@ -4009,23 +3633,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moka"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"equivalent",
|
||||
"parking_lot 0.12.5",
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
@@ -4077,21 +3684,9 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nobilis"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -4108,9 +3703,6 @@ dependencies = [
|
||||
"flate2",
|
||||
"fs2",
|
||||
"futures",
|
||||
"grammers-client",
|
||||
"grammers-mtsender",
|
||||
"grammers-session",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http-body-util",
|
||||
@@ -4226,17 +3818,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -4366,17 +3947,6 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
@@ -4400,16 +3970,6 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
@@ -4423,51 +3983,6 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -4497,69 +4012,11 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oneshot-fused-workaround"
|
||||
@@ -4686,21 +4143,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"nix",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.6.1"
|
||||
@@ -4842,7 +4284,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5011,7 +4452,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5058,21 +4499,6 @@ dependencies = [
|
||||
"universal-hash 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postage"
|
||||
version = "0.5.0"
|
||||
@@ -5118,17 +4544,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prefix-trie"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
|
||||
dependencies = [
|
||||
"either",
|
||||
"ipnet",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
@@ -5629,12 +5044,6 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolv-conf"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
||||
|
||||
[[package]]
|
||||
name = "retry-error"
|
||||
version = "0.14.0"
|
||||
@@ -6103,7 +5512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -6329,9 +5738,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -6861,33 +6270,6 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tagptr"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
@@ -7046,9 +6428,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.3"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -8627,7 +8009,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"thiserror 2.0.21",
|
||||
"x25519-dalek 3.0.0",
|
||||
@@ -8837,12 +8219,6 @@ dependencies = [
|
||||
"safe_arch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wildmatch"
|
||||
version = "2.6.1"
|
||||
@@ -8963,17 +8339,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -9419,12 +8784,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
+1
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nobilis"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
description = "Chat daemon: IRC, Discord, Sneedchat and Matrix behind one unified JSON model over a Unix socket"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -59,12 +59,6 @@ qrcode = { version = "0.14", features = ["image"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "gif"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
|
||||
# Telegram backend (MTProto, pure Rust). `grammers-session` without its default `sqlite-storage` (libsql would sit
|
||||
# beside rusqlite and matrix-sdk-sqlite): the session is serialized into the account instead - see backend/telegram.
|
||||
grammers-client = { version = "=0.11.0", default-features = false, features = ["proxy", "fs"] }
|
||||
grammers-mtsender = { version = "=0.11.0", default-features = false, features = ["proxy"] }
|
||||
grammers-session = { version = "=0.11.0", default-features = false, features = ["serde"] }
|
||||
|
||||
# Sneedchat backend (SneedChat over embedded Tor)
|
||||
arti-client = { version = "0.47", default-features = false, features = ["tokio", "rustls", "onion-service-client", "compression"] }
|
||||
tor-rtcompat = { version = "0.47", features = ["tokio", "rustls"] }
|
||||
|
||||
+6
-222
@@ -390,8 +390,9 @@ pub struct SteamAccountConfig {
|
||||
/// sign-in so Steam Guard does not ask for a code every time.
|
||||
#[serde(default)]
|
||||
pub guard_data: Option<String>,
|
||||
/// Which kind of session this account signed in as. Unused now: moho signs in as a desktop client of its own
|
||||
/// (see backend/steam/auth.rs); kept so files written when it was chosen read the same.
|
||||
/// Which kind of session this account signed in as. Always `chat`, the
|
||||
/// mobile app's, which lives beside the Steam launcher; kept so files
|
||||
/// written when the desktop kind could still be chosen read the same.
|
||||
#[serde(default)]
|
||||
pub session: String,
|
||||
/// Whether this account's connections go through Tor or the SOCKS5 proxy
|
||||
@@ -489,54 +490,6 @@ impl TwitchAccountConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Telegram account: a phone number's, signed in over MTProto (see backend/telegram).
|
||||
///
|
||||
/// Telegram gives the owner of a phone number one `api_id` and `api_hash`, and a client sends them with every
|
||||
/// connection. Moho carries none of its own: the ones the person typed into the sign-in form are kept here, with
|
||||
/// the account, and are as private as the session is. The session itself (the authorization key Telegram issued,
|
||||
/// and what a client caches to catch up on updates) is large and changes often, so it is a file of its own in the
|
||||
/// daemon's data directory (see backend/telegram/session.rs), not a field of this one.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct TelegramAccountConfig {
|
||||
#[serde(default)]
|
||||
pub api_id: i32,
|
||||
#[serde(default)]
|
||||
pub api_hash: String,
|
||||
/// The number the account was signed in with, in international form (`+15551234567`).
|
||||
#[serde(default)]
|
||||
pub phone: String,
|
||||
/// Telegram's number for the account, which survives the number or the name being changed. The account's id here
|
||||
/// is built from it.
|
||||
#[serde(default)]
|
||||
pub user_id: i64,
|
||||
/// The `@name`, if the account has one. Telegram's, not ours.
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
/// The account's own name in Telegram (first and last), for the sidebar.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// What to call this account here. Local: Telegram is never told about it.
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
/// Whether this account's connections go through Tor or the SOCKS5 proxy set in the network settings.
|
||||
#[serde(default)]
|
||||
pub use_tor: bool,
|
||||
/// Strict routing: everything the window loads for this account's conversations goes through the route too.
|
||||
#[serde(default)]
|
||||
pub strict_route: bool,
|
||||
}
|
||||
|
||||
impl TelegramAccountConfig {
|
||||
pub fn account_id(&self) -> String {
|
||||
format!("telegram:{}", self.user_id)
|
||||
}
|
||||
|
||||
/// Whether there is a sign-in behind this account: one that has been through is what has an id.
|
||||
pub fn is_signed_in(&self) -> bool {
|
||||
self.user_id != 0 && self.api_id != 0 && !self.api_hash.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
struct AccountsFile {
|
||||
#[serde(default, rename = "account")]
|
||||
@@ -557,8 +510,6 @@ struct AccountsFile {
|
||||
steam: Vec<SteamAccountConfig>,
|
||||
#[serde(default, rename = "twitch_account")]
|
||||
twitch: Vec<TwitchAccountConfig>,
|
||||
#[serde(default, rename = "telegram_account")]
|
||||
telegram: Vec<TelegramAccountConfig>,
|
||||
}
|
||||
|
||||
/// Account/credential persistence at ~/.config/nobilis/accounts.toml,
|
||||
@@ -578,8 +529,6 @@ pub struct AccountStore {
|
||||
steam: Mutex<HashMap<String, SteamAccountConfig>>,
|
||||
/// Read by `persist` the same way, for the same reason.
|
||||
twitch: Mutex<HashMap<String, TwitchAccountConfig>>,
|
||||
/// And this one.
|
||||
telegram: Mutex<HashMap<String, TelegramAccountConfig>>,
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
@@ -590,7 +539,7 @@ impl AccountStore {
|
||||
}
|
||||
|
||||
pub fn open(path: PathBuf) -> Result<Self> {
|
||||
let (irc, discord, sneedchat, matrix, kick, steam, twitch, telegram) = if path.exists() {
|
||||
let (irc, discord, sneedchat, matrix, kick, steam, twitch) = if path.exists() {
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let file: AccountsFile = toml::from_str(&text)
|
||||
@@ -602,10 +551,9 @@ impl AccountStore {
|
||||
let kick = file.kick.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let steam = file.steam.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let twitch = file.twitch.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
let telegram = file.telegram.into_iter().map(|a| (a.account_id(), a)).collect();
|
||||
(irc, discord, sneedchat, matrix, kick, steam, twitch, telegram)
|
||||
(irc, discord, sneedchat, matrix, kick, steam, twitch)
|
||||
} else {
|
||||
(HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new())
|
||||
(HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new())
|
||||
};
|
||||
Ok(Self {
|
||||
path,
|
||||
@@ -616,7 +564,6 @@ impl AccountStore {
|
||||
kick: Mutex::new(kick),
|
||||
steam: Mutex::new(steam),
|
||||
twitch: Mutex::new(twitch),
|
||||
telegram: Mutex::new(telegram),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -639,7 +586,6 @@ impl AccountStore {
|
||||
kick: kick.values().cloned().collect(),
|
||||
steam: self.steam.lock().unwrap().values().cloned().collect(),
|
||||
twitch: self.twitch.lock().unwrap().values().cloned().collect(),
|
||||
telegram: self.telegram.lock().unwrap().values().cloned().collect(),
|
||||
};
|
||||
let text = toml::to_string_pretty(&file)?;
|
||||
// Atomic write-temp-then-rename, same spirit as libpurple's periodic
|
||||
@@ -877,48 +823,6 @@ impl AccountStore {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_telegram(&self, account_id: &str) -> Option<TelegramAccountConfig> {
|
||||
self.telegram.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn all_telegram(&self) -> Vec<TelegramAccountConfig> {
|
||||
self.telegram.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Upserts: signing in again as the same account is how a session Telegram has ended gets replaced. What the
|
||||
/// person set up on the account - its route, its name here - stays.
|
||||
pub fn add_telegram(&self, mut config: TelegramAccountConfig) -> Result<TelegramAccountConfig> {
|
||||
let id = config.account_id();
|
||||
{
|
||||
let mut telegram = self.telegram.lock().unwrap();
|
||||
if let Some(old) = telegram.get(&id) {
|
||||
config.strict_route |= old.strict_route;
|
||||
config.use_tor |= old.use_tor;
|
||||
if config.display_name.is_none() {
|
||||
config.display_name = old.display_name.clone();
|
||||
}
|
||||
}
|
||||
telegram.insert(id, config.clone());
|
||||
}
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Changes one Telegram account, and writes the file. `false` when there is no such account.
|
||||
///
|
||||
/// The lock is let go before the write: `persist` takes it again.
|
||||
pub fn mutate_telegram(&self, account_id: &str, change: impl FnOnce(&mut TelegramAccountConfig)) -> Result<bool> {
|
||||
{
|
||||
let mut telegram = self.telegram.lock().unwrap();
|
||||
match telegram.get_mut(account_id) {
|
||||
None => return Ok(false),
|
||||
Some(account) => change(account),
|
||||
}
|
||||
}
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Upserts, like the others: signing in to Kick again with the same
|
||||
/// username is how a rejected token gets replaced.
|
||||
pub fn add_kick(&self, mut config: KickAccountConfig) -> Result<KickAccountConfig> {
|
||||
@@ -1061,10 +965,6 @@ impl AccountStore {
|
||||
a.use_tor = routed;
|
||||
a.strict_route = strict;
|
||||
true
|
||||
} else if let Some(a) = self.telegram.lock().unwrap().get_mut(account_id) {
|
||||
a.use_tor = routed;
|
||||
a.strict_route = strict;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -1095,9 +995,6 @@ impl AccountStore {
|
||||
if let Some(a) = self.get_twitch(account_id) {
|
||||
return pick(a.use_tor, a.strict_route);
|
||||
}
|
||||
if let Some(a) = self.get_telegram(account_id) {
|
||||
return pick(a.use_tor, a.strict_route);
|
||||
}
|
||||
self.get_kick(account_id).and_then(|a| pick(a.use_tor, a.strict_route))
|
||||
}
|
||||
|
||||
@@ -1185,13 +1082,6 @@ impl AccountStore {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
{
|
||||
let removed = self.telegram.lock().unwrap().remove(account_id).is_some();
|
||||
if removed {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let mut kick = self.kick.lock().unwrap();
|
||||
if kick.remove(account_id).is_some() {
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &kick)?;
|
||||
@@ -1356,9 +1246,6 @@ impl AccountStore {
|
||||
if self.mutate_twitch(account_id, |a| a.display_name = value.clone())? {
|
||||
return Ok(true);
|
||||
}
|
||||
if self.mutate_telegram(account_id, |a| a.display_name = value.clone())? {
|
||||
return Ok(true);
|
||||
}
|
||||
let mut kick = self.kick.lock().unwrap();
|
||||
match kick.get_mut(account_id) {
|
||||
None => Ok(false),
|
||||
@@ -1395,9 +1282,6 @@ impl AccountStore {
|
||||
if let Some(a) = self.twitch.lock().unwrap().get(account_id) {
|
||||
return named(&a.display_name);
|
||||
}
|
||||
if let Some(a) = self.telegram.lock().unwrap().get(account_id) {
|
||||
return named(&a.display_name);
|
||||
}
|
||||
self.kick.lock().unwrap().get(account_id).and_then(|a| named(&a.display_name))
|
||||
}
|
||||
|
||||
@@ -1743,50 +1627,6 @@ pub fn twitch_account_to_json(a: &TwitchAccountConfig, state: &str) -> Account {
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram has none of IRC's concepts either. `has_password` says there is a sign-in behind the account, which is
|
||||
/// what "signed in" means for one that never keeps a password.
|
||||
pub fn telegram_account_to_json(a: &TelegramAccountConfig, state: &str) -> Account {
|
||||
Account {
|
||||
id: a.account_id(),
|
||||
service: "telegram".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_text: String::new(),
|
||||
auto_idle: false,
|
||||
display_name: a
|
||||
.display_name
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| Some(a.name.clone()).filter(|s| !s.is_empty()))
|
||||
.or_else(|| a.username.clone().filter(|s| !s.is_empty()))
|
||||
.unwrap_or_else(|| a.phone.clone()),
|
||||
state: state.to_string(),
|
||||
autojoin: String::new(),
|
||||
has_nickserv_password: false,
|
||||
sasl_enabled: false,
|
||||
sasl_username: String::new(),
|
||||
allow_plaintext_sasl: false,
|
||||
reconnect_for_sasl: false,
|
||||
sasl_mechanism: String::new(),
|
||||
has_sasl_certificate: false,
|
||||
current_nick: String::new(),
|
||||
highlight_keywords: Vec::new(),
|
||||
ssl: true,
|
||||
has_password: a.is_signed_in(),
|
||||
avatar_url: None,
|
||||
sneedchat_rooms: Vec::new(),
|
||||
tor_mode: None,
|
||||
tor_proxy: None,
|
||||
use_tor: a.use_tor,
|
||||
route_level: route_level(a.use_tor, a.strict_route),
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
web_session: false,
|
||||
moderation: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Matrix has none of IRC's autojoin/SASL/NickServ concepts either - same
|
||||
/// zero-value convention as discord_account_to_json/sneedchat_account_to_json.
|
||||
/// `has_key_backup` comes from the caller (Runtime::list_accounts) rather
|
||||
@@ -2041,59 +1881,3 @@ mod twitch_account_tests {
|
||||
assert!(reopened.all_twitch().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod telegram_account_tests {
|
||||
use super::*;
|
||||
|
||||
fn store(name: &str) -> (AccountStore, PathBuf) {
|
||||
let path = std::env::temp_dir().join(format!("nobilis-telegram-accounts-{name}-{}.toml", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
(AccountStore::open(path.clone()).unwrap(), path)
|
||||
}
|
||||
|
||||
fn account(user_id: i64) -> TelegramAccountConfig {
|
||||
TelegramAccountConfig { api_id: 12345, api_hash: "abcdef".into(), phone: "+15551234567".into(), user_id, name: "Some One".into(), username: Some("someone".into()), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_telegram_account_survives_a_restart_without_its_session_in_the_file() {
|
||||
let (s, path) = store("restart");
|
||||
s.add_telegram(account(777)).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("[[telegram_account]]"), "{text}");
|
||||
assert!(!text.contains("session"), "the session is a file of its own, not a field here: {text}");
|
||||
let reopened = AccountStore::open(path.clone()).unwrap();
|
||||
let got = reopened.get_telegram("telegram:777").expect("found under the id built from Telegram's number");
|
||||
assert_eq!((got.api_id, got.api_hash.as_str(), got.phone.as_str(), got.username.as_deref()), (12345, "abcdef", "+15551234567", Some("someone")));
|
||||
assert!(got.is_signed_in());
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_in_again_keeps_what_the_person_set_up_and_removing_takes_only_that_account() {
|
||||
let (s, path) = store("again");
|
||||
s.add_telegram(account(1)).unwrap();
|
||||
s.add_telegram(account(2)).unwrap();
|
||||
s.set_route_level("telegram:1", "strict").unwrap();
|
||||
s.set_display_name("telegram:1", "Me on Telegram").unwrap();
|
||||
s.add_telegram(TelegramAccountConfig { name: "Renamed".into(), ..account(1) }).unwrap();
|
||||
let got = s.get_telegram("telegram:1").unwrap();
|
||||
assert_eq!((got.name.as_str(), got.display_name.as_deref(), got.strict_route), ("Renamed", Some("Me on Telegram"), true));
|
||||
assert_eq!(s.route_level_of("telegram:1").as_deref(), Some("strict"));
|
||||
assert_eq!(s.all_telegram().len(), 2);
|
||||
assert!(s.remove("telegram:1").unwrap());
|
||||
assert!(!s.remove("telegram:1").unwrap());
|
||||
assert!(s.get_telegram("telegram:2").is_some());
|
||||
assert!(AccountStore::open(path.clone()).unwrap().get_telegram("telegram:1").is_none(), "the file forgot too");
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listed_account_is_named_for_the_person_and_says_it_is_signed_in() {
|
||||
let listed = telegram_account_to_json(&account(5), "connected");
|
||||
assert_eq!((listed.service.as_str(), listed.id.as_str(), listed.display_name.as_str(), listed.has_password), ("telegram", "telegram:5", "Some One", true));
|
||||
let unnamed = telegram_account_to_json(&TelegramAccountConfig { name: String::new(), username: None, ..account(5) }, "connected");
|
||||
assert_eq!(unnamed.display_name, "+15551234567");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,27 +107,6 @@ pub async fn member_verification(state: &AppState, account_id: &str, guild_id: &
|
||||
serde_json::from_str(&text).context("parsing this server's rules")
|
||||
}
|
||||
|
||||
/// The form's fields with the answers filled in: the rules are agreed to, and every other field takes what the person
|
||||
/// gave for it (their text, or the number of the choice), by its place in the form. A field nobody answered is sent
|
||||
/// without a response, which Discord accepts only where the server did not require one.
|
||||
pub fn responses_for(form: &Value, answers: &[Value]) -> Vec<Value> {
|
||||
form["form_fields"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let mut field = f.clone();
|
||||
if f["field_type"] == "TERMS" {
|
||||
field["response"] = Value::Bool(true);
|
||||
} else if let Some(answer) = answers.get(i).filter(|a| !a.is_null()) {
|
||||
field["response"] = answer.clone();
|
||||
}
|
||||
field
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Agrees to them, which is what lifts the gate.
|
||||
///
|
||||
/// The form is fetched again rather than taken from the caller: what is
|
||||
@@ -135,11 +114,20 @@ pub fn responses_for(form: &Value, answers: &[Value]) -> Vec<Value> {
|
||||
/// captured when the dialog opened may not still be it. Every field is
|
||||
/// returned as sent with its response set - Discord rejects a form that has
|
||||
/// been reshaped, and it is the server's form, not ours to edit.
|
||||
pub async fn accept_member_verification(state: &AppState, account_id: &str, guild_id: &str, answers: &[Value]) -> Result<()> {
|
||||
pub async fn accept_member_verification(state: &AppState, account_id: &str, guild_id: &str) -> Result<()> {
|
||||
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
|
||||
let form = member_verification(state, account_id, guild_id).await?;
|
||||
let version = form["version"].clone();
|
||||
let fields = responses_for(&form, answers);
|
||||
let fields: Vec<Value> = form["form_fields"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|f| {
|
||||
let mut field = f.clone();
|
||||
field["response"] = Value::Bool(true);
|
||||
field
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resp = send_write(
|
||||
http_client_for(&cfg.token)
|
||||
@@ -1229,29 +1217,3 @@ mod system_dm_tests {
|
||||
assert!(!is_system_dm(&json!({ "recipients": [{ "id": "1", "system": true }, { "id": "2" }] })));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod verification_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_rules_are_agreed_to_and_other_fields_take_the_persons_answers() {
|
||||
let form = json!({
|
||||
"version": "2026-01-01",
|
||||
"form_fields": [
|
||||
{ "field_type": "TERMS", "label": "Read and agree to the server rules", "values": ["Be kind"], "required": true },
|
||||
{ "field_type": "TEXT_INPUT", "label": "Why are you joining?", "required": true },
|
||||
{ "field_type": "MULTIPLE_CHOICE", "label": "Pick one", "values": ["a", "b"], "required": false },
|
||||
]
|
||||
});
|
||||
let sent = responses_for(&form, &[Value::Null, json!("to talk"), json!(1)]);
|
||||
assert_eq!(sent[0]["response"], true);
|
||||
assert_eq!(sent[1]["response"], "to talk");
|
||||
assert_eq!(sent[2]["response"], 1);
|
||||
assert_eq!(sent[0]["values"], json!(["Be kind"]), "the server's own form is not reshaped");
|
||||
// Nothing answered: no response key at all, not a made-up one.
|
||||
let bare = responses_for(&form, &[]);
|
||||
assert!(bare[1].get("response").is_none() && bare[2].get("response").is_none());
|
||||
assert_eq!(bare[0]["response"], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,7 +910,6 @@ pub(super) fn extract_reply(d: &Value) -> Option<ReplyPreview> {
|
||||
thread: false,
|
||||
forwarded: true,
|
||||
thread_root: None,
|
||||
chat: None,
|
||||
});
|
||||
}
|
||||
let referenced = &d["referenced_message"];
|
||||
@@ -922,7 +921,7 @@ pub(super) fn extract_reply(d: &Value) -> Option<ReplyPreview> {
|
||||
let author = &referenced["author"];
|
||||
let from = author["global_name"].as_str().filter(|s| !s.is_empty()).or_else(|| author["username"].as_str()).unwrap_or("unknown").to_string();
|
||||
let body = extract_body(referenced).unwrap_or_default();
|
||||
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false, thread_root: None, chat: None })
|
||||
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false, thread_root: None })
|
||||
}
|
||||
|
||||
/// Shared by both the initial backfill and extend_history - Discord
|
||||
|
||||
@@ -589,7 +589,6 @@ pub(super) fn reply_preview(metadata: Option<&ReplyMetadata>, thread_parent: Opt
|
||||
thread: false,
|
||||
forwarded: false,
|
||||
thread_root: Some(thread_root),
|
||||
chat: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(super) fn relation_preview(state: &AppState, buffer_id: &str, content: &Valu
|
||||
let in_thread = thread.is_some();
|
||||
let target = thread.or_else(|| protocol::reply_target(content))?;
|
||||
match state.store.get_message(buffer_id, target) {
|
||||
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target.to_string(), from: m.from, body: m.body, thread: in_thread, forwarded: false, thread_root: None, chat: None }),
|
||||
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target.to_string(), from: m.from, body: m.body, thread: in_thread, forwarded: false, thread_root: None }),
|
||||
_ => Some(crate::model::ReplyPreview { id: target.to_string(), thread: in_thread, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,4 @@ pub mod kick;
|
||||
pub mod matrix;
|
||||
pub mod sneedchat;
|
||||
pub mod steam;
|
||||
pub mod telegram;
|
||||
pub mod twitch;
|
||||
|
||||
+14
-94
@@ -27,75 +27,19 @@ use base64::Engine;
|
||||
use rsa::{BigUint, Pkcs1v15Encrypt, RsaPublicKey};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What moho signs in as: a desktop Steam client, the way every other third-party client does.
|
||||
/// What moho signs in as: the Steam mobile app's chat session.
|
||||
///
|
||||
/// This is part of the token Steam issues and decides how Steam treats the connection that uses it. It was the
|
||||
/// mobile app's chat session (platform 3, app type 2, website "Mobile") until Steam raised an account alert for an
|
||||
/// unexpected device: a "phone" that reports a Linux desktop's operating system, has no machine id and logs on again
|
||||
/// every time its connection is closed does not look like any real device. SteamKit2, ArchiSteamFarm, node-steam-user,
|
||||
/// node-steam-session and steam-vent all sign in as the desktop client (platform 1, website "Client"), and so does this.
|
||||
/// This is part of the token Steam issues and decides how Steam treats the
|
||||
/// connection that uses it. The desktop client's session is one per account,
|
||||
/// so signing in as it would sign the Steam launcher out (and the reverse).
|
||||
/// The mobile chat session is chat and friends only, and lives beside the
|
||||
/// launcher. It is the only kind moho offers.
|
||||
///
|
||||
/// `EAuthTokenPlatformType` 1 is the desktop client. It has no app type.
|
||||
const PLATFORM_TYPE: i32 = 1;
|
||||
const WEBSITE_ID: &str = "Client";
|
||||
/// `EGamingDeviceType` 1 is an ordinary PC, as node-steam-session sends for the desktop client.
|
||||
const GAMING_DEVICE_TYPE: u32 = 1;
|
||||
|
||||
/// This install's own random number, made once and kept next to the daemon's other files, from which its machine
|
||||
/// id and logon id are drawn. One per install and not per account: a machine is one machine however many accounts
|
||||
/// sign in on it, and two installs (a test window, a second computer) are two.
|
||||
pub fn install_id() -> [u8; 16] {
|
||||
// A test never writes to the real data directory.
|
||||
#[cfg(test)]
|
||||
return [7u8; 16];
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
static ID: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new();
|
||||
*ID.get_or_init(|| {
|
||||
let path = crate::default_data_dir().join("steam-install-id");
|
||||
if let Some(read) = std::fs::read_to_string(&path).ok().and_then(|t| hex::decode(t.trim()).ok()).and_then(|b| <[u8; 16]>::try_from(b).ok()) {
|
||||
return read;
|
||||
}
|
||||
let made: [u8; 16] = rand::random();
|
||||
let _ = std::fs::create_dir_all(path.parent().unwrap_or(std::path::Path::new(".")));
|
||||
let _ = std::fs::write(&path, hex::encode(made));
|
||||
made
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine id Steam's client sends: a binary key-value object of three SHA-1 hashes (named BB3, FF2 and 3B3 in the
|
||||
/// client), here of strings made from the install's number, so it is the same each time and different for each install.
|
||||
pub fn machine_id_from(install: &[u8; 16]) -> Vec<u8> {
|
||||
use sha1::{Digest, Sha1};
|
||||
let mut out = vec![0u8];
|
||||
out.extend_from_slice(b"MessageObject\0");
|
||||
for name in ["BB3", "FF2", "3B3"] {
|
||||
let hash = hex::encode(Sha1::digest(format!("moho {name} {}", hex::encode(install)).as_bytes()));
|
||||
out.push(1);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.push(0);
|
||||
out.extend_from_slice(hash.as_bytes());
|
||||
out.push(0);
|
||||
}
|
||||
out.extend_from_slice(&[8, 8]);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn machine_id() -> Vec<u8> {
|
||||
machine_id_from(&install_id())
|
||||
}
|
||||
|
||||
/// What tells this install's sessions from another's on the same account: Steam ends a session when a later one with
|
||||
/// the same logon id arrives, and lets those with different ones live side by side. The desktop client sends its own
|
||||
/// private address here; this sends a number of its own, never zero (which is "none").
|
||||
pub fn logon_id_from(install: &[u8; 16]) -> u32 {
|
||||
(u32::from_le_bytes([install[0], install[1], install[2], install[3]]) | 1) ^ 0xBAAD_F00D
|
||||
}
|
||||
|
||||
pub fn logon_id() -> u32 {
|
||||
logon_id_from(&install_id())
|
||||
}
|
||||
/// `EAuthTokenPlatformType` 3 is the mobile app.
|
||||
const PLATFORM_TYPE: i32 = 3;
|
||||
/// `EAuthTokenAppType` 2 is the mobile chat app.
|
||||
const APP_TYPE: i32 = 2;
|
||||
const WEBSITE_ID: &str = "Mobile";
|
||||
|
||||
/// `EOSType` as the connection reports it: Linux 6.x is -184 and Windows 10
|
||||
/// is 16, sent as the unsigned number the field is.
|
||||
@@ -211,9 +155,7 @@ fn device_details() -> DeviceDetails {
|
||||
device_friendly_name: Some(device_name()),
|
||||
platform_type: Some(PLATFORM_TYPE),
|
||||
os_type: Some(os_type() as i32),
|
||||
gaming_device_type: Some(GAMING_DEVICE_TYPE),
|
||||
// Sent with a password sign-in, as the desktop client's is.
|
||||
machine_id: Some(machine_id()),
|
||||
app_type: Some(APP_TYPE),
|
||||
..DeviceDetails::default()
|
||||
}
|
||||
}
|
||||
@@ -491,11 +433,8 @@ mod tests {
|
||||
assert_eq!(begin_req.encryption_timestamp, Some(1790000005000));
|
||||
assert_eq!(begin_req.persistence, Some(1));
|
||||
assert_eq!(begin_req.guard_data.as_deref(), Some("old-guard"));
|
||||
assert_eq!(begin_req.platform_type, Some(1), "it signs in as a desktop client, like every other third-party client");
|
||||
assert_eq!(begin_req.website_id.as_deref(), Some("Client"));
|
||||
let details = begin_req.device_details.unwrap();
|
||||
assert_eq!((details.platform_type, details.app_type, details.gaming_device_type), (Some(1), None, Some(1)));
|
||||
assert_eq!(details.machine_id.as_deref(), Some(machine_id().as_slice()), "and with its install's machine id");
|
||||
assert_eq!(begin_req.platform_type, Some(3), "it signs in as the mobile app, never as the desktop client");
|
||||
assert_eq!(begin_req.device_details.unwrap().app_type, Some(2));
|
||||
assert!(!begin_req.encrypted_password.unwrap().contains("hunter2"));
|
||||
|
||||
let code_req = UpdateAuthWithGuardCodeRequest::decode(seen[2].body.as_slice()).unwrap();
|
||||
@@ -580,23 +519,4 @@ mod tests {
|
||||
assert_eq!(steamid_of_token("not a token"), None);
|
||||
assert_eq!(steamid_of_token("a.b.c"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_machine_id_is_a_key_value_object_of_three_hashes_that_is_the_same_for_one_install_and_not_for_another() {
|
||||
let one = machine_id_from(&[1u8; 16]);
|
||||
assert_eq!(one, machine_id_from(&[1u8; 16]));
|
||||
assert_ne!(one, machine_id_from(&[2u8; 16]));
|
||||
// 0x00 "MessageObject" 0x00, then three strings named BB3, FF2 and 3B3 of forty hex digits, then 0x08 0x08.
|
||||
assert!(one.starts_with(b"\0MessageObject\0\x01BB3\0"));
|
||||
assert!(one.ends_with(&[0, 8, 8]));
|
||||
let text = String::from_utf8_lossy(&one).to_string();
|
||||
for name in ["BB3", "FF2", "3B3"] {
|
||||
assert!(text.contains(name));
|
||||
}
|
||||
assert_eq!(one.len(), 1 + 14 + 3 * (1 + 3 + 1 + 40 + 1) + 2);
|
||||
assert_ne!(logon_id_from(&[1u8; 16]), 0);
|
||||
assert_ne!(logon_id_from(&[1u8; 16]), logon_id_from(&[2u8; 16]));
|
||||
// Never the same as the zero a client with no id of its own sends.
|
||||
assert_ne!(logon_id_from(&[0xFF; 16]), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ pub const PROTOCOL_VERSION: u32 = 65581;
|
||||
/// How long Steam has to answer a sign-in.
|
||||
const LOGON_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(10);
|
||||
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(300);
|
||||
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(3);
|
||||
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long to leave an account alone after Steam says it is being asked too
|
||||
/// often. Seconds would be asking again; this is the length of time that the
|
||||
@@ -124,11 +124,10 @@ pub fn logon_message(config: &SteamAccountConfig, language: &str) -> ClientLogon
|
||||
should_remember_password: Some(true),
|
||||
// Where the machine is on its own network is nothing Steam needs to
|
||||
// be told; the field exists and asks to be filled.
|
||||
obfuscated_private_ip: Some(IpAddress { ip: Some(ip_address::Ip::V4(auth::logon_id())) }),
|
||||
obfuscated_private_ip: Some(IpAddress { ip: Some(ip_address::Ip::V4(0)) }),
|
||||
chat_mode: Some(2),
|
||||
account_name: Some(config.username.clone()),
|
||||
machine_name: Some(auth::device_name()),
|
||||
machine_id: Some(auth::machine_id()),
|
||||
supports_rate_limit_response: Some(true),
|
||||
// The refresh token, which is what this field takes whatever its name.
|
||||
access_token: Some(config.refresh_token.clone()),
|
||||
|
||||
@@ -305,7 +305,7 @@ pub async fn flow(
|
||||
display_name: None,
|
||||
refresh_token: tokens.refresh_token,
|
||||
guard_data: tokens.guard_data,
|
||||
// The kind is no longer chosen; kept so older files still read.
|
||||
// Always the mobile chat session; kept so older files still read.
|
||||
session: "chat".to_string(),
|
||||
use_tor: crate::net::route::router().wanted(&key),
|
||||
strict_route: false,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
//! What an administrator may do to a member: remove, ban, silence.
|
||||
//!
|
||||
//! The menu beside the member list asks what this account may do here, and offers only that. Telegram has the last
|
||||
//! word: an administrator who lacks the particular right is refused ("Only an administrator may do that here").
|
||||
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_session::types::{PeerId, PeerKind};
|
||||
use grammers_session::Session;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long a mute lasts, as everywhere else in moho.
|
||||
const MUTE: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// What this account may do to others in a conversation, in the shape the member menu reads.
|
||||
pub async fn permissions(state: &AppState, buffer: &Buffer) -> Result<Value, String> {
|
||||
let none = json!({ "canRedactOthers": false, "canKick": false, "canBan": false, "canMute": false });
|
||||
if buffer.kind == "dm" {
|
||||
return Ok(none);
|
||||
}
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let me = handle.client.get_me().await.map_err(|e| explain(&e))?;
|
||||
let Ok(Some(me)) = me.to_ref().await else { return Ok(none) };
|
||||
let Ok(found) = handle.client.get_permissions(peer, me).await else { return Ok(none) };
|
||||
let may = found.is_creator() || found.is_admin();
|
||||
// A small group can only have a member removed; bans and silences are for supergroups and channels.
|
||||
let supergroup = peer.id.kind() == PeerKind::Channel;
|
||||
Ok(json!({ "canRedactOthers": may, "canKick": may, "canBan": may && supergroup, "canMute": may && supergroup }))
|
||||
}
|
||||
|
||||
/// Removes, bans or silences one member.
|
||||
pub async fn moderate(state: &AppState, buffer: &Buffer, user_id: &str, action: &str) -> Result<(), String> {
|
||||
let id: i64 = user_id.parse().map_err(|_| "that member cannot be told apart from the others".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let user = handle.session.peer_ref(PeerId::user_unchecked(id)).await.ok().flatten().ok_or_else(|| "Telegram has not told this account about that member yet".to_string())?;
|
||||
let supergroup = peer.id.kind() == PeerKind::Channel;
|
||||
match action {
|
||||
"kick" => handle.client.kick_participant(peer, user).await.map_err(|e| explain(&e)),
|
||||
"ban" if supergroup => handle.client.set_banned_rights(peer, user).view_messages(false).await.map_err(|e| explain(&e)),
|
||||
"mute" if supergroup => handle.client.set_banned_rights(peer, user).send_messages(false).duration(MUTE).await.map_err(|e| explain(&e)),
|
||||
"ban" | "mute" => Err("Telegram only lets a supergroup or a channel ban or silence somebody".to_string()),
|
||||
_ => Err("Telegram has no such action".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
//! An account's connection: open the session it saved, stay connected, and reconnect when Telegram or the network
|
||||
//! lets go - without ever signing in afresh to get over a hiccup (see mod.rs on why).
|
||||
//!
|
||||
//! Modelled on Steam's loop (`steam/connect.rs run_with_retry`): a wait that grows while a connection falls over at
|
||||
//! once and starts again after one that held, a long wait when Telegram says to slow down, and a stop - not a retry
|
||||
//! - when Telegram has ended the session, because the same key can never work again.
|
||||
|
||||
use super::link::{self, Link};
|
||||
use super::messages::{self, Own};
|
||||
use super::peers;
|
||||
use super::Handle;
|
||||
use super::session::{self, FileSession};
|
||||
use crate::accounts::TelegramAccountConfig;
|
||||
use crate::runtime::ConnState;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{Context, Result};
|
||||
use grammers_client::InvocationError;
|
||||
use grammers_mtsender::UpdatesConfiguration;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(10);
|
||||
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How often a changed session is written.
|
||||
const SAVE_EVERY: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Ends the account's session at Telegram, so removing it here does not leave a session in the account's list of
|
||||
/// devices, and takes the saved session away. Best effort: the saved one is gone either way.
|
||||
pub async fn sign_out(state: &AppState, config: &TelegramAccountConfig) {
|
||||
let path = session::path_for(config.user_id);
|
||||
if let Ok(session) = FileSession::open(path.clone()) {
|
||||
let session = Arc::new(session);
|
||||
if session.has_key() {
|
||||
// The account was removed first, so its own key is the only route it has left: not Tor unless it was.
|
||||
match link::open(state, &config.account_id(), config.api_id, session.clone()).await {
|
||||
Ok(link) => {
|
||||
if let Err(e) = link.client.sign_out().await {
|
||||
tracing::info!("telegram[{}]: signing out at Telegram: {e}", config.account_id());
|
||||
}
|
||||
link.close().await;
|
||||
}
|
||||
Err(e) => tracing::info!("telegram[{}]: could not reach Telegram to sign out: {e:#}", config.account_id()),
|
||||
}
|
||||
}
|
||||
session.delete();
|
||||
} else {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a connection ended.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Ending {
|
||||
/// Telegram ended the session (or it is gone): sign in again.
|
||||
SignInAgain(String),
|
||||
/// Telegram asked for a pause, or the network is down: try again after this.
|
||||
Later(Duration, String),
|
||||
/// The connection ended; try again after the usual wait.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// What an error from Telegram means for the connection.
|
||||
pub fn classify(error: &InvocationError) -> Ending {
|
||||
match error {
|
||||
InvocationError::Rpc(rpc) => {
|
||||
let name = rpc.name.as_str();
|
||||
if rpc.code == 401 || matches!(name, "AUTH_KEY_UNREGISTERED" | "SESSION_REVOKED" | "SESSION_EXPIRED" | "USER_DEACTIVATED" | "USER_DEACTIVATED_BAN" | "AUTH_KEY_DUPLICATED") {
|
||||
Ending::SignInAgain(super::login::explain(name))
|
||||
} else if name == "FLOOD_WAIT" || name == "FLOOD_PREMIUM_WAIT" {
|
||||
// The number of seconds Telegram asked for, and a little more.
|
||||
Ending::Later(Duration::from_secs(u64::from(rpc.value.unwrap_or(60)) + 5), format!("Telegram asked for a pause of {} seconds", rpc.value.unwrap_or(60)))
|
||||
} else {
|
||||
Ending::Closed
|
||||
}
|
||||
}
|
||||
_ => Ending::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts (or restarts) an account's connection.
|
||||
pub fn spawn(state: AppState, config: TelegramAccountConfig) {
|
||||
let account_id = config.account_id();
|
||||
crate::net::route::router().mark(&account_id, config.use_tor);
|
||||
state.runtime.reset_connection(&account_id);
|
||||
let handle = tokio::spawn({
|
||||
let account_id = account_id.clone();
|
||||
let state = state.clone();
|
||||
async move {
|
||||
run_with_retry(&state, &account_id).await;
|
||||
state.runtime.remove_task_handle(&account_id);
|
||||
}
|
||||
});
|
||||
state.runtime.insert_task_handle(&account_id, handle.abort_handle());
|
||||
}
|
||||
|
||||
async fn run_with_retry(state: &AppState, account_id: &str) {
|
||||
let mut delay = RECONNECT_INITIAL_DELAY;
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
|
||||
loop {
|
||||
// The account as it is now: removed while this slept, it is not reconnected.
|
||||
let Some(config) = state.accounts.get_telegram(account_id) else { return };
|
||||
let (detail, wait) = match run(state, &config, account_id).await {
|
||||
Ok(Ending::SignInAgain(why)) => {
|
||||
tracing::info!("telegram[{account_id}]: stopped: {why}");
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::AuthFailed, Some(&format!("{why}. Sign in to Telegram again from Accounts.")));
|
||||
return;
|
||||
}
|
||||
Ok(Ending::Later(wait, why)) => (why, Some(wait)),
|
||||
Ok(Ending::Closed) => ("connection ended".to_string(), None),
|
||||
Err(e) => {
|
||||
tracing::warn!("telegram[{account_id}]: {e:#}");
|
||||
(format!("{e:#}"), None)
|
||||
}
|
||||
};
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
|
||||
let wait = wait.unwrap_or_else(|| state.runtime.next_retry_delay(account_id, &mut delay, RECONNECT_INITIAL_DELAY, RECONNECT_MAX_DELAY));
|
||||
tracing::info!("telegram[{account_id}]: not connected ({detail}); trying again in {}s", wait.as_secs());
|
||||
state.runtime.report_retry(state, account_id, &detail, wait);
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One go at being connected.
|
||||
async fn run(state: &AppState, config: &TelegramAccountConfig, account_id: &str) -> Result<Ending> {
|
||||
let session = Arc::new(FileSession::open(session::path_for(config.user_id))?);
|
||||
if !session.has_key() {
|
||||
return Ok(Ending::SignInAgain("this account has no saved session".to_string()));
|
||||
}
|
||||
let mut link = link::open(state, account_id, config.api_id, session).await?;
|
||||
let ending = stay(state, config, account_id, &mut link).await;
|
||||
state.runtime.clear_telegram_handle(account_id);
|
||||
link.close().await;
|
||||
ending
|
||||
}
|
||||
|
||||
async fn stay(state: &AppState, config: &TelegramAccountConfig, account_id: &str, link: &mut Link) -> Result<Ending> {
|
||||
match link.client.is_authorized().await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(Ending::SignInAgain("Telegram no longer accepts this session".to_string())),
|
||||
Err(e) => return Ok(classify(&e)).and_then(|ending| if ending == Ending::Closed { Err(anyhow::Error::from(e)).context("asking Telegram whether the session is good") } else { Ok(ending) }),
|
||||
}
|
||||
// The name and number the account has now, which are Telegram's to change.
|
||||
let mut own = Own { name: if config.name.is_empty() { config.phone.clone() } else { config.name.clone() }, user_id: config.user_id };
|
||||
if let Ok(me) = link.client.get_me().await {
|
||||
let (name, username) = (me.full_name(), me.username().map(str::to_string));
|
||||
if !name.is_empty() {
|
||||
own.name = name.clone();
|
||||
}
|
||||
let _ = state.accounts.mutate_telegram(account_id, |a| {
|
||||
a.name = name;
|
||||
a.username = username;
|
||||
});
|
||||
}
|
||||
state.runtime.set_own_identity(account_id, &own.name);
|
||||
let handle = Handle { client: link.client.clone(), session: link.session.clone(), own: own.clone(), names: Default::default() };
|
||||
state.runtime.set_telegram_handle(account_id, handle.clone());
|
||||
state.runtime.set_conn_state(state, account_id, ConnState::Connected, None);
|
||||
tracing::info!("telegram[{account_id}]: connected as {} ({})", config.user_id, config.username.as_deref().unwrap_or("no username"));
|
||||
// The conversations, in the background: the connection is up whether or not this succeeds.
|
||||
tokio::spawn({
|
||||
let (state, account_id, handle) = (state.clone(), account_id.to_string(), handle.clone());
|
||||
async move {
|
||||
if let Err(e) = super::dialogs::sync(&state, &account_id, &handle).await {
|
||||
tracing::warn!("telegram[{account_id}]: reading the conversations: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let updates = link.updates.take().context("the updates were already taken")?;
|
||||
let mut stream = link.client.stream_updates(updates, UpdatesConfiguration { catch_up: true }).await.map_err(|e| anyhow::anyhow!("starting to read updates: {e}"))?;
|
||||
let mut save = tokio::time::interval(SAVE_EVERY);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = save.tick() => {
|
||||
if let Err(e) = link.session.save_if_dirty() {
|
||||
tracing::warn!("telegram[{account_id}]: saving the session: {e:#}");
|
||||
}
|
||||
}
|
||||
update = stream.next() => match update {
|
||||
Ok(update) => handle_update(state, account_id, &handle, update).await,
|
||||
Err(e) => {
|
||||
let _ = stream.sync_update_state().await;
|
||||
return match classify(&e) {
|
||||
Ending::Closed => Err(anyhow::Error::from(e)).context("reading updates"),
|
||||
other => Ok(other),
|
||||
};
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What Telegram sent unasked: a message, an edit, something deleted.
|
||||
async fn handle_update(state: &AppState, account_id: &str, handle: &Handle, update: grammers_client::update::Update) {
|
||||
use grammers_client::update::Update;
|
||||
match update {
|
||||
Update::NewMessage(message) => {
|
||||
let recorded = match message.peer() {
|
||||
Some(_) => messages::record_named(state, account_id, &handle.own, &message, Some(&handle.names)),
|
||||
// A short update from a person names no one: asked of Telegram, who is the other side.
|
||||
None => {
|
||||
let (state, account_id, handle) = (state.clone(), account_id.to_string(), handle.clone());
|
||||
tokio::spawn(async move { record_unnamed(&state, &account_id, &handle, &message).await });
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::debug!("telegram[{account_id}]: new message in {:?}: recorded {recorded}", message.peer_id());
|
||||
}
|
||||
Update::Raw(raw) => super::updates::raw(state, account_id, handle, &raw.raw),
|
||||
Update::MessageEdited(message) => {
|
||||
let peer = match message.peer() {
|
||||
Some(peer) => Some(peer.clone()),
|
||||
None => resolve(handle, &message).await,
|
||||
};
|
||||
if let Some(peer) = peer {
|
||||
let buffer = peers::buffer_for(state, account_id, &peer);
|
||||
let body = messages::body_of(&message);
|
||||
let attachments = buffer.remote_id.as_deref().map(|remote| super::media::attachments_of(handle.own.user_id, remote, &message)).unwrap_or_default();
|
||||
state.runtime.update_message(state, &buffer.id, &message.id().to_string(), &body, &[], &attachments);
|
||||
}
|
||||
}
|
||||
Update::MessageDeleted(deleted) => {
|
||||
for id in deleted.messages() {
|
||||
forget_message(state, account_id, deleted.channel_id(), *id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversation a message from a short update belongs to, from what the session knows of the person.
|
||||
async fn resolve(handle: &Handle, message: &grammers_client::message::Message) -> Option<grammers_client::peer::Peer> {
|
||||
let peer_ref = message.peer_ref().await.ok().flatten()?;
|
||||
handle.client.resolve_peer(peer_ref).await.ok()
|
||||
}
|
||||
|
||||
async fn record_unnamed(state: &AppState, account_id: &str, handle: &Handle, message: &grammers_client::message::Message) {
|
||||
match resolve(handle, message).await {
|
||||
Some(peer) => {
|
||||
messages::record_in(state, account_id, &handle.own, message, &peer, None, Some(&handle.names));
|
||||
}
|
||||
None => tracing::warn!("telegram[{account_id}]: a message in {:?} from somebody this account has no record of was not shown", message.peer_id()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a message Telegram says is gone. A channel's deletion names the channel; any other names only the number,
|
||||
/// which is unique across the account's people and small groups, so the conversations are looked through.
|
||||
fn forget_message(state: &AppState, account_id: &str, channel: Option<i64>, id: i32) {
|
||||
let msg_id = id.to_string();
|
||||
let candidates: Vec<crate::model::Buffer> = state
|
||||
.runtime
|
||||
.list_buffers()
|
||||
.into_iter()
|
||||
.filter(|b| b.account_id == account_id)
|
||||
.filter(|b| match (channel, b.remote_id.as_deref()) {
|
||||
(Some(c), Some(remote)) => remote == peers::remote_id(grammers_session::types::PeerId::channel_unchecked(c)),
|
||||
(Some(_), None) => false,
|
||||
(None, Some(remote)) => !remote.starts_with("-100"),
|
||||
(None, None) => false,
|
||||
})
|
||||
.collect();
|
||||
for buffer in candidates {
|
||||
if state.store.get_message(&buffer.id, &msg_id).ok().flatten().is_some() {
|
||||
state.runtime.delete_message(state, &buffer.id, &msg_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use grammers_mtsender::RpcError;
|
||||
|
||||
fn rpc(code: i32, name: &str, value: Option<u32>) -> InvocationError {
|
||||
InvocationError::Rpc(RpcError { code, name: name.to_string(), value, caused_by: None })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_telegram_ended_stops_the_connection_and_a_flood_wait_waits_as_long_as_asked() {
|
||||
assert!(matches!(classify(&rpc(401, "AUTH_KEY_UNREGISTERED", None)), Ending::SignInAgain(_)));
|
||||
assert!(matches!(classify(&rpc(401, "SESSION_REVOKED", None)), Ending::SignInAgain(_)));
|
||||
assert!(matches!(classify(&rpc(420, "FLOOD_WAIT", Some(120))), Ending::Later(d, _) if d == Duration::from_secs(125)));
|
||||
assert_eq!(classify(&rpc(500, "INTERDC_CALL_ERROR", None)), Ending::Closed);
|
||||
assert_eq!(classify(&InvocationError::Io(std::io::Error::other("reset"))), Ending::Closed);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//! The conversations an account has: asked for when the connection comes up, and each one's last message with it.
|
||||
//!
|
||||
//! Telegram's dialog list answers with every conversation and, in the same response, its last message, so one
|
||||
//! request gives the list its names, kinds and recent activity without opening any of them. History proper is read
|
||||
//! when a conversation is opened (see `history`).
|
||||
|
||||
use super::media;
|
||||
use super::messages;
|
||||
use super::Handle;
|
||||
use super::peers;
|
||||
use crate::state::AppState;
|
||||
use anyhow::Result;
|
||||
use grammers_client::tl;
|
||||
|
||||
/// How many conversations are read on connect. Telegram pages them a hundred at a time.
|
||||
const LIMIT: usize = 200;
|
||||
|
||||
/// Makes a buffer of each of the account's conversations, newest activity first, and records the last message of each.
|
||||
pub async fn sync(state: &AppState, account_id: &str, handle: &Handle) -> Result<usize> {
|
||||
let mut dialogs = handle.client.iter_dialogs();
|
||||
let mut count = 0;
|
||||
while count < LIMIT {
|
||||
let Some(dialog) = dialogs.next().await.map_err(|e| anyhow::anyhow!("{e}"))? else { break };
|
||||
let buffer = peers::buffer_for(state, account_id, dialog.peer());
|
||||
// Filed again each time: a bot read before bots had a heading of their own was filed with the people.
|
||||
state.runtime.set_buffer_category(state, &buffer.id, Some(peers::category_of(dialog.peer())), buffer.position);
|
||||
state.runtime.set_buffer_name_icon(state, &buffer.id, peers::name_icon_of(dialog.peer()));
|
||||
if let grammers_client::peer::Peer::User(user) = dialog.peer() {
|
||||
handle.names.lock().unwrap_or_else(|e| e.into_inner()).insert(user.id().bare_id_unchecked(), peers::title_of(dialog.peer()));
|
||||
}
|
||||
if let Some(last) = &dialog.last_message {
|
||||
messages::record_named(state, account_id, &handle.own, last, Some(&handle.names));
|
||||
}
|
||||
// Silenced on the account, perhaps on the phone.
|
||||
if let tl::enums::Dialog::Dialog(d) = &dialog.raw {
|
||||
state.runtime.set_silenced(state, &buffer.id, super::notify::silent(&d.notify_settings, chrono::Utc::now().timestamp()));
|
||||
}
|
||||
// Nothing unread there, as Telegram counts it: the window need not call it unread either.
|
||||
if matches!(&dialog.raw, tl::enums::Dialog::Dialog(d) if d.unread_count == 0) {
|
||||
state.events.emit("bufferRead", serde_json::json!({ "accountId": account_id, "bufferId": buffer.id }));
|
||||
}
|
||||
if let Some(avatar) = media::avatar_url(handle.own.user_id, dialog.peer()) {
|
||||
state.runtime.set_buffer_avatar(state, &buffer.id, &avatar);
|
||||
}
|
||||
// Whether the account may write there, read off the dialog's own copy of the chat (which carries its rights):
|
||||
// where it may not, the window shows why in place of the message box.
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
state.runtime.set_buffer_read_only(state, &buffer.id, peers::read_only_for(dialog.peer(), now));
|
||||
count += 1;
|
||||
}
|
||||
tracing::info!("telegram[{account_id}]: {count} conversations");
|
||||
Ok(count)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
//! A conversation's history, read when it is opened and again when it is scrolled back.
|
||||
//!
|
||||
//! Telegram keeps every message, so opening a conversation asks for its recent end, and scrolling to the top asks for
|
||||
//! the part before. What is stored is returned by `getBacklog` either way, so a failure to reach Telegram leaves what
|
||||
//! there is rather than an error.
|
||||
|
||||
use super::peers;
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{anyhow, Result};
|
||||
use grammers_session::Session;
|
||||
|
||||
/// How many messages a page asks for.
|
||||
const PAGE: usize = 50;
|
||||
|
||||
async fn read(state: &AppState, buffer: &Buffer, before: Option<i64>) -> Result<usize> {
|
||||
let handle = state.runtime.telegram_handle(&buffer.account_id).ok_or_else(|| anyhow!("this Telegram account is not connected"))?;
|
||||
let peer = buffer.remote_id.as_deref().and_then(peers::peer_id_of).ok_or_else(|| anyhow!("this is not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(peer).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that conversation yet"))?;
|
||||
let mut iter = handle.client.iter_messages(peer).limit(PAGE);
|
||||
if let Some(ts) = before.filter(|t| *t > 0) {
|
||||
iter = iter.offset_date(ts.clamp(0, i64::from(i32::MAX)) as i32);
|
||||
}
|
||||
let mut got = Vec::new();
|
||||
while let Some(message) = iter.next().await.map_err(|e| anyhow!("{e}"))? {
|
||||
got.push(message);
|
||||
}
|
||||
// Oldest first, so a reply finds the message it answers already stored.
|
||||
got.reverse();
|
||||
let count = got.len();
|
||||
for message in &got {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, message);
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Opening a conversation: its recent end.
|
||||
pub async fn open(state: &AppState, buffer: &Buffer) -> Result<usize> {
|
||||
read(state, buffer, None).await
|
||||
}
|
||||
|
||||
/// Scrolling back: what came before `before` (a Unix time).
|
||||
pub async fn older(state: &AppState, buffer: &Buffer, before: i64) -> Result<usize> {
|
||||
read(state, buffer, Some(before)).await
|
||||
}
|
||||
|
||||
/// Reads the messages around one, so a jump to an old message (a pin, a reply, a search result) arrives with what was
|
||||
/// said before and after it. Returns the message's time.
|
||||
pub async fn context(state: &AppState, buffer: &Buffer, msg_id: &str) -> Result<i64> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| anyhow!("that is not a Telegram message"))?;
|
||||
let handle = state.runtime.telegram_handle(&buffer.account_id).ok_or_else(|| anyhow!("this Telegram account is not connected"))?;
|
||||
let peer = buffer.remote_id.as_deref().and_then(peers::peer_id_of).ok_or_else(|| anyhow!("this is not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(peer).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that conversation yet"))?;
|
||||
let mut around = Vec::new();
|
||||
// Itself and what came before it, newest first...
|
||||
let mut older = handle.client.iter_messages(peer).offset_id(id.saturating_add(1)).limit(26);
|
||||
while let Some(message) = older.next().await.map_err(|e| anyhow!("{e}"))? {
|
||||
around.push(message);
|
||||
}
|
||||
// ...and what came after, oldest first.
|
||||
let mut newer = handle.client.iter_messages(peer).reverse(true).offset_id(id).limit(25);
|
||||
while let Some(message) = newer.next().await.map_err(|e| anyhow!("{e}"))? {
|
||||
around.push(message);
|
||||
}
|
||||
around.sort_by_key(|m| m.id());
|
||||
for message in &around {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, message);
|
||||
}
|
||||
around.iter().find(|m| m.id() == id).map(|m| m.date().as_second()).ok_or_else(|| anyhow!("that message is no longer there"))
|
||||
}
|
||||
|
||||
/// Reads on from a message towards the present, for a reader who arrived in the middle of a conversation.
|
||||
pub async fn newer(state: &AppState, buffer: &Buffer, msg_id: &str) -> Result<usize> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| anyhow!("that is not a Telegram message"))?;
|
||||
let handle = state.runtime.telegram_handle(&buffer.account_id).ok_or_else(|| anyhow!("this Telegram account is not connected"))?;
|
||||
let peer = buffer.remote_id.as_deref().and_then(peers::peer_id_of).ok_or_else(|| anyhow!("this is not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(peer).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that conversation yet"))?;
|
||||
let mut iter = handle.client.iter_messages(peer).reverse(true).offset_id(id).limit(PAGE);
|
||||
let mut count = 0;
|
||||
while let Some(message) = iter.next().await.map_err(|e| anyhow!("{e}"))? {
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, &message);
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
//! Instant View: the article a link's preview carries, drawn as a page of its own.
|
||||
//!
|
||||
//! A message that links to a page Telegram has made an Instant View of carries the page's blocks (headings,
|
||||
//! paragraphs with their styling, pictures, lists, quotes, tables) as a tree. The window opens it in a window of its
|
||||
//! own, so this turns the tree into one self-contained HTML document: everything in it is escaped here, pictures are
|
||||
//! fetched into the cache and named by their local file, and links are limited to the kinds a page may reasonably have.
|
||||
//! What it cannot draw (embedded players, maps, other channels' posts) is left out, with the page's own link on the
|
||||
//! card to open it in a browser instead.
|
||||
|
||||
use super::media::{download_thumb, download_to, media_name, pick, Variant, MEDIA};
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::media::{Document, Media, Photo};
|
||||
use grammers_client::tl;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tl::enums::{PageBlock as B, RichText as R};
|
||||
|
||||
/// A finished article: its title, for the window, and the document.
|
||||
pub struct Article {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
/// What a page needs fetched: a picture, or a video's poster.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
enum Want {
|
||||
Photo(i64),
|
||||
Poster(i64),
|
||||
}
|
||||
|
||||
struct Cx {
|
||||
account: i64,
|
||||
remote: String,
|
||||
message: i32,
|
||||
photos: HashMap<i64, tl::enums::Photo>,
|
||||
documents: HashMap<i64, tl::enums::Document>,
|
||||
wanted: Vec<Want>,
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An address a page's link may point to: web pages, mail, phones and Telegram's own, or an anchor in the page.
|
||||
fn safe_url(url: &str) -> String {
|
||||
let lower = url.trim().to_ascii_lowercase();
|
||||
let ok = ["http://", "https://", "mailto:", "tel:", "tg://", "#"].iter().any(|p| lower.starts_with(p));
|
||||
if ok { escape(url.trim()) } else { "#".to_string() }
|
||||
}
|
||||
|
||||
fn percent(path: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in path.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Cx {
|
||||
fn path(&self, want: Want) -> PathBuf {
|
||||
let (kind, id) = match want {
|
||||
Want::Photo(id) => ("iv", id),
|
||||
Want::Poster(id) => ("ivp", id),
|
||||
};
|
||||
let name = media_name(self.account, &self.remote, self.message, Variant::Full, "jpg").replace("_full.jpg", &format!("_{kind}{id}.jpg"));
|
||||
crate::media_cache::dir(MEDIA).join(name)
|
||||
}
|
||||
|
||||
/// The address of a picture the page uses, once it is fetched; `None` when the page does not carry it.
|
||||
fn picture(&mut self, want: Want) -> Option<String> {
|
||||
let known = match want {
|
||||
Want::Photo(id) => self.photos.contains_key(&id),
|
||||
Want::Poster(id) => self.documents.contains_key(&id),
|
||||
};
|
||||
if !known {
|
||||
tracing::info!("telegram: an article uses {want:?}, which its page does not carry ({} pictures, {} files)", self.photos.len(), self.documents.len());
|
||||
return None;
|
||||
}
|
||||
if !self.wanted.contains(&want) {
|
||||
self.wanted.push(want);
|
||||
}
|
||||
Some(format!("moho-media://file/?p={}", percent(&self.path(want).display().to_string())))
|
||||
}
|
||||
}
|
||||
|
||||
fn rich(t: &R, cx: &mut Cx) -> String {
|
||||
let wrap = |tag: &str, inner: &R, cx: &mut Cx| format!("<{tag}>{}</{tag}>", rich(inner, cx));
|
||||
match t {
|
||||
R::TextEmpty => String::new(),
|
||||
R::TextPlain(p) => escape(&p.text).replace('\n', "<br>"),
|
||||
R::TextBold(b) => wrap("b", &b.text, cx),
|
||||
R::TextItalic(i) => wrap("i", &i.text, cx),
|
||||
R::TextUnderline(u) => wrap("u", &u.text, cx),
|
||||
R::TextStrike(s) => wrap("s", &s.text, cx),
|
||||
R::TextFixed(f) => wrap("code", &f.text, cx),
|
||||
R::TextSubscript(s) => wrap("sub", &s.text, cx),
|
||||
R::TextSuperscript(s) => wrap("sup", &s.text, cx),
|
||||
R::TextMarked(m) => wrap("mark", &m.text, cx),
|
||||
R::TextUrl(u) => format!("<a href=\"{}\">{}</a>", safe_url(&u.url), rich(&u.text, cx)),
|
||||
R::TextEmail(e) => format!("<a href=\"mailto:{}\">{}</a>", safe_url(&e.email).trim_start_matches("mailto:"), rich(&e.text, cx)),
|
||||
R::TextPhone(p) => format!("<a href=\"tel:{}\">{}</a>", escape(&p.phone), rich(&p.text, cx)),
|
||||
R::TextConcat(c) => c.texts.iter().map(|t| rich(t, cx)).collect(),
|
||||
R::TextAnchor(a) => format!("<span id=\"{}\">{}</span>", escape(&a.name), rich(&a.text, cx)),
|
||||
R::TextSpoiler(s) => rich(&s.text, cx),
|
||||
R::TextMention(m) => rich(&m.text, cx),
|
||||
R::TextHashtag(m) => rich(&m.text, cx),
|
||||
R::TextBotCommand(m) => rich(&m.text, cx),
|
||||
R::TextCashtag(m) => rich(&m.text, cx),
|
||||
R::TextBankCard(m) => rich(&m.text, cx),
|
||||
R::TextAutoUrl(m) => rich(&m.text, cx),
|
||||
R::TextAutoEmail(m) => rich(&m.text, cx),
|
||||
R::TextAutoPhone(m) => rich(&m.text, cx),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plain(t: &R) -> String {
|
||||
let mut cx = Cx { account: 0, remote: String::new(), message: 0, photos: HashMap::new(), documents: HashMap::new(), wanted: Vec::new() };
|
||||
let html = rich(t, &mut cx);
|
||||
// Only for a title: the tags taken out again.
|
||||
let mut out = String::new();
|
||||
let mut inside = false;
|
||||
for c in html.chars() {
|
||||
match c {
|
||||
'<' => inside = true,
|
||||
'>' => inside = false,
|
||||
c if !inside => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").trim().to_string()
|
||||
}
|
||||
|
||||
fn is_empty(t: &R) -> bool {
|
||||
matches!(t, R::TextEmpty) || plain(t).is_empty()
|
||||
}
|
||||
|
||||
fn caption(c: &tl::enums::PageCaption, cx: &mut Cx) -> String {
|
||||
let tl::enums::PageCaption::Caption(c) = c;
|
||||
let (text, credit) = (rich(&c.text, cx), rich(&c.credit, cx));
|
||||
if text.is_empty() && credit.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("<figcaption>{text}{}</figcaption>", if credit.is_empty() { String::new() } else { format!("<cite>{credit}</cite>") })
|
||||
}
|
||||
|
||||
fn blocks(list: &[B], cx: &mut Cx) -> String {
|
||||
list.iter().map(|b| block(b, cx)).collect()
|
||||
}
|
||||
|
||||
fn heading(tag: &str, t: &R, cx: &mut Cx) -> String {
|
||||
format!("<{tag}>{}</{tag}>", rich(t, cx))
|
||||
}
|
||||
|
||||
fn block(b: &B, cx: &mut Cx) -> String {
|
||||
match b {
|
||||
B::Title(t) => heading("h1", &t.text, cx),
|
||||
B::Subtitle(t) => format!("<p class=\"subtitle\">{}</p>", rich(&t.text, cx)),
|
||||
B::Kicker(t) => format!("<p class=\"kicker\">{}</p>", rich(&t.text, cx)),
|
||||
B::AuthorDate(a) => {
|
||||
let who = rich(&a.author, cx);
|
||||
let when = chrono::DateTime::from_timestamp(i64::from(a.published_date), 0).filter(|_| a.published_date > 0).map(|d| d.format("%B %-d, %Y").to_string());
|
||||
let parts: Vec<String> = [Some(who).filter(|w| !w.is_empty()), when].into_iter().flatten().collect();
|
||||
if parts.is_empty() { String::new() } else { format!("<p class=\"byline\">{}</p>", parts.join(" · ")) }
|
||||
}
|
||||
B::Header(h) => heading("h2", &h.text, cx),
|
||||
B::Heading1(h) => heading("h2", &h.text, cx),
|
||||
B::Heading2(h) => heading("h3", &h.text, cx),
|
||||
B::Heading3(h) => heading("h4", &h.text, cx),
|
||||
B::Heading4(h) => heading("h5", &h.text, cx),
|
||||
B::Heading5(h) => heading("h6", &h.text, cx),
|
||||
B::Heading6(h) => heading("h6", &h.text, cx),
|
||||
B::Subheader(t) => heading("h3", &t.text, cx),
|
||||
B::Paragraph(p) => {
|
||||
let inner = rich(&p.text, cx);
|
||||
if inner.is_empty() { String::new() } else { format!("<p>{inner}</p>") }
|
||||
}
|
||||
B::Preformatted(p) => format!("<pre><code>{}</code></pre>", rich(&p.text, cx)),
|
||||
B::Footer(f) => format!("<p class=\"footer\">{}</p>", rich(&f.text, cx)),
|
||||
B::Divider => "<hr>".to_string(),
|
||||
B::Anchor(a) => format!("<a id=\"{}\"></a>", escape(&a.name)),
|
||||
B::List(l) => {
|
||||
let items: String = l
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
tl::enums::PageListItem::Text(t) => format!("<li>{}</li>", rich(&t.text, cx)),
|
||||
tl::enums::PageListItem::Blocks(b) => format!("<li>{}</li>", blocks(&b.blocks, cx)),
|
||||
})
|
||||
.collect();
|
||||
format!("<ul>{items}</ul>")
|
||||
}
|
||||
B::OrderedList(l) => {
|
||||
let items: String = l
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
tl::enums::PageListOrderedItem::Text(t) => format!("<li>{}</li>", rich(&t.text, cx)),
|
||||
tl::enums::PageListOrderedItem::Blocks(b) => format!("<li>{}</li>", blocks(&b.blocks, cx)),
|
||||
})
|
||||
.collect();
|
||||
let start = l.start.map(|s| format!(" start=\"{s}\"")).unwrap_or_default();
|
||||
format!("<ol{start}{}>{items}</ol>", if l.reversed { " reversed" } else { "" })
|
||||
}
|
||||
B::Blockquote(q) => {
|
||||
let who = rich(&q.caption, cx);
|
||||
format!("<blockquote><p>{}</p>{}</blockquote>", rich(&q.text, cx), if who.is_empty() { String::new() } else { format!("<cite>{who}</cite>") })
|
||||
}
|
||||
B::BlockquoteBlocks(q) => format!("<blockquote>{}</blockquote>", blocks(&q.blocks, cx)),
|
||||
B::Pullquote(q) => {
|
||||
let who = rich(&q.caption, cx);
|
||||
format!("<blockquote class=\"pull\"><p>{}</p>{}</blockquote>", rich(&q.text, cx), if who.is_empty() { String::new() } else { format!("<cite>{who}</cite>") })
|
||||
}
|
||||
B::Photo(p) => {
|
||||
// A picture the page does not carry still has its words.
|
||||
let img = match cx.picture(Want::Photo(p.photo_id)) {
|
||||
Some(src) => {
|
||||
let img = format!("<img src=\"{src}\" alt=\"\">");
|
||||
match &p.url {
|
||||
Some(url) => format!("<a href=\"{}\">{img}</a>", safe_url(url)),
|
||||
None => img,
|
||||
}
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
format!("<figure>{img}{}</figure>", caption(&p.caption, cx))
|
||||
}
|
||||
B::Video(v) => {
|
||||
let poster = cx.picture(Want::Poster(v.video_id)).map(|src| format!("<img src=\"{src}\" alt=\"\">")).unwrap_or_default();
|
||||
format!("<figure class=\"video\">{poster}<span class=\"note\">Video: open the link above to watch it</span>{}</figure>", caption(&v.caption, cx))
|
||||
}
|
||||
B::Cover(c) => format!("<div class=\"cover\">{}</div>", block(&c.cover, cx)),
|
||||
B::Collage(c) => format!("<figure class=\"collage\"><div>{}</div>{}</figure>", blocks(&c.items, cx), caption(&c.caption, cx)),
|
||||
B::Slideshow(c) => format!("<figure class=\"collage\"><div>{}</div>{}</figure>", blocks(&c.items, cx), caption(&c.caption, cx)),
|
||||
B::Embed(e) => {
|
||||
let poster = e.poster_photo_id.and_then(|id| cx.picture(Want::Photo(id))).map(|src| format!("<img src=\"{src}\" alt=\"\">")).unwrap_or_default();
|
||||
let link = e.url.as_deref().map(|u| format!("<a class=\"note\" href=\"{}\">Open the embedded content</a>", safe_url(u))).unwrap_or_default();
|
||||
format!("<figure>{poster}{link}{}</figure>", caption(&e.caption, cx))
|
||||
}
|
||||
B::Table(t) => {
|
||||
let rows: String = t
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let tl::enums::PageTableRow::Row(row) = row;
|
||||
let cells: String = row
|
||||
.cells
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
let tl::enums::PageTableCell::Cell(c) = cell;
|
||||
let tag = if c.header { "th" } else { "td" };
|
||||
let span = format!("{}{}", c.colspan.map(|n| format!(" colspan=\"{n}\"")).unwrap_or_default(), c.rowspan.map(|n| format!(" rowspan=\"{n}\"")).unwrap_or_default());
|
||||
let align = if c.align_center { " class=\"c\"" } else if c.align_right { " class=\"r\"" } else { "" };
|
||||
format!("<{tag}{span}{align}>{}</{tag}>", c.text.as_ref().map(|t| rich(t, cx)).unwrap_or_default())
|
||||
})
|
||||
.collect();
|
||||
format!("<tr>{cells}</tr>")
|
||||
})
|
||||
.collect();
|
||||
let title = rich(&t.title, cx);
|
||||
format!("<div class=\"table\"><table>{}{rows}</table></div>", if title.is_empty() { String::new() } else { format!("<caption>{title}</caption>") })
|
||||
}
|
||||
B::Details(d) => format!("<details{}><summary>{}</summary>{}</details>", if d.open { " open" } else { "" }, rich(&d.title, cx), blocks(&d.blocks, cx)),
|
||||
B::Math(m) => format!("<pre><code>{}</code></pre>", escape(&m.source)),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
const STYLE: &str = "
|
||||
:root{color-scheme:light dark;--bg:#fff;--fg:#1c2733;--muted:#6b7a89;--line:#d8dee4;--link:#2b7bd3;--soft:#f2f5f8}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0e1621;--fg:#e4e9ee;--muted:#8a99a8;--line:#26323f;--link:#6ab3f3;--soft:#17212b}}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg)}
|
||||
body{font:17px/1.65 system-ui,-apple-system,'Segoe UI','Noto Sans',sans-serif}
|
||||
main{max-width:740px;margin:0 auto;padding:0 20px 80px}
|
||||
a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
|
||||
h1{font-size:2rem;line-height:1.2;margin:.8em 0 .4em}h2{font-size:1.5rem;margin:1.4em 0 .4em}h3{font-size:1.25rem;margin:1.3em 0 .3em}h4,h5,h6{margin:1.2em 0 .3em}
|
||||
p{margin:.9em 0}.kicker{color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:.8rem;margin:1.6em 0 0}
|
||||
.subtitle{color:var(--muted);font-size:1.15rem}.byline,.footer{color:var(--muted);font-size:.9rem}
|
||||
figure{margin:1.4em -20px}figure img,.cover img{display:block;width:100%;height:auto}figcaption{padding:.5em 20px 0;color:var(--muted);font-size:.9rem}figcaption cite{display:block;font-style:normal;opacity:.8}
|
||||
.cover{margin:0 -20px}.cover figure{margin:0}.cover figcaption{padding:.7em 20px;text-transform:uppercase;letter-spacing:.04em;font-size:.8rem}.cover figcaption cite{margin:.6em -20px -.7em;padding:.6em 20px;background:var(--soft);color:var(--fg);font-size:1rem;font-weight:600;text-transform:none;letter-spacing:0;opacity:1}.collage div{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:4px}.collage figure{margin:0}
|
||||
.note{display:block;padding:.6em 20px;color:var(--muted);font-size:.9rem}
|
||||
blockquote{margin:1.2em 0;padding:.1em 1em;border-left:3px solid var(--link);color:var(--fg)}blockquote.pull{font-size:1.25rem;border-left:0;text-align:center;font-style:italic}cite{display:block;color:var(--muted);font-style:normal;font-size:.9rem}
|
||||
pre{background:var(--soft);padding:12px 14px;overflow:auto;border-radius:6px;font-size:.9rem}code{font-family:ui-monospace,Menlo,Consolas,monospace}mark{background:#ffe08a55;color:inherit}
|
||||
hr{border:0;border-top:1px solid var(--line);margin:2em 0}ul,ol{padding-left:1.4em}li{margin:.3em 0}
|
||||
.table{overflow:auto;margin:1.2em 0}table{border-collapse:collapse;width:100%}td,th{border:1px solid var(--line);padding:6px 10px;vertical-align:top}th{background:var(--soft)}.c{text-align:center}.r{text-align:right}caption{caption-side:top;text-align:left;font-weight:600;padding-bottom:.4em}
|
||||
details{margin:1em 0;border:1px solid var(--line);border-radius:6px;padding:.4em .9em}summary{cursor:pointer;font-weight:600}
|
||||
";
|
||||
|
||||
/// The article a message's link preview carries, with its pictures fetched.
|
||||
pub async fn article(state: &AppState, buffer: &Buffer, msg_id: &str) -> Result<Article, String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message has no article".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let found = handle.client.get_messages_by_id(peer, &[id]).await.map_err(|e| explain(&e))?;
|
||||
let Some(Some(message)) = found.into_iter().next() else { return Err("that message is no longer there".to_string()) };
|
||||
let Some(Media::WebPage(media)) = message.media() else { return Err("that message links to no page".to_string()) };
|
||||
let tl::enums::WebPage::Page(page) = &media.raw.webpage else { return Err("Telegram has no preview of that page".to_string()) };
|
||||
let url = page.url.clone();
|
||||
let title = page.title.clone().unwrap_or_default();
|
||||
let (preview_photo, preview_document) = (page.photo.clone(), page.document.clone());
|
||||
let mut cached = page.cached_page.clone();
|
||||
// A message may carry only the start of the page: the whole of it is asked for.
|
||||
if matches!(&cached, Some(tl::enums::Page::Page(p)) if p.part) {
|
||||
if let Ok(tl::enums::messages::WebPage::Page(full)) = handle.client.invoke(&tl::functions::messages::GetWebPage { url: url.clone(), hash: 0 }).await {
|
||||
if let tl::enums::WebPage::Page(whole) = full.webpage {
|
||||
cached = whole.cached_page.or(cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(tl::enums::Page::Page(doc)) = cached else { return Err("Telegram has no Instant View of that page".to_string()) };
|
||||
let remote = buffer.remote_id.clone().unwrap_or_default();
|
||||
let mut cx = Cx {
|
||||
account: handle.own.user_id,
|
||||
remote,
|
||||
message: id,
|
||||
// The page's own picture (the one its preview shows) is often the cover and is not listed again with the rest.
|
||||
photos: doc.photos.iter().chain(preview_photo.iter()).filter_map(|p| match p { tl::enums::Photo::Photo(x) => Some((x.id, p.clone())), _ => None }).collect(),
|
||||
documents: doc.documents.iter().chain(preview_document.iter()).filter_map(|d| match d { tl::enums::Document::Document(x) => Some((x.id, d.clone())), _ => None }).collect(),
|
||||
wanted: Vec::new(),
|
||||
};
|
||||
let body = blocks(&doc.blocks, &mut cx);
|
||||
// The page's own title, from its first heading when the preview gave none.
|
||||
let title = if title.is_empty() { doc.blocks.iter().find_map(|b| if let B::Title(t) = b { Some(plain(&t.text)).filter(|t| !t.is_empty()) } else { None }).unwrap_or_default() } else { title };
|
||||
let has_title = doc.blocks.iter().any(|b| matches!(b, B::Title(t) if !is_empty(&t.text)));
|
||||
let heading = if has_title || title.is_empty() { String::new() } else { format!("<h1>{}</h1>", escape(&title)) };
|
||||
|
||||
// The pictures, fetched together; one that will not come leaves a gap where it was.
|
||||
let mut jobs = tokio::task::JoinSet::new();
|
||||
for want in cx.wanted.clone() {
|
||||
let path = cx.path(want);
|
||||
if path.exists() {
|
||||
continue;
|
||||
}
|
||||
let client = handle.client.clone();
|
||||
let (photo, document) = match want {
|
||||
Want::Photo(id) => (cx.photos.get(&id).cloned(), None),
|
||||
Want::Poster(id) => (None, cx.documents.get(&id).cloned()),
|
||||
};
|
||||
jobs.spawn(async move {
|
||||
let done = if let Some(photo) = photo {
|
||||
let photo = Photo::from_raw(photo);
|
||||
match pick(&photo.thumbs(), 1280) {
|
||||
Some(size) => download_to(&client, size, &path, false).await,
|
||||
None => download_to(&client, &photo, &path, false).await,
|
||||
}
|
||||
} else if let Some(document) = document {
|
||||
let document = Document::from_raw_media(tl::types::MessageMediaDocument {
|
||||
nopremium: false,
|
||||
spoiler: false,
|
||||
video: true,
|
||||
round: false,
|
||||
voice: false,
|
||||
document: Some(document),
|
||||
alt_documents: None,
|
||||
video_cover: None,
|
||||
video_timestamp: None,
|
||||
ttl_seconds: None,
|
||||
});
|
||||
download_thumb(&client, &document, &path).await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = done {
|
||||
tracing::debug!("telegram: a picture of an article: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
while jobs.join_next().await.is_some() {}
|
||||
|
||||
let rtl = if doc.rtl { " dir=\"rtl\"" } else { "" };
|
||||
let html = format!(
|
||||
"<!doctype html><html lang=\"en\"{rtl}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
|
||||
<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src moho-media: data:; style-src 'unsafe-inline'\">\
|
||||
<title>{}</title><style>{STYLE}</style></head><body><main>{heading}{body}<p class=\"footer\"><a href=\"{}\">Open the original page</a></p></main></body></html>",
|
||||
escape(&title),
|
||||
safe_url(&url)
|
||||
);
|
||||
Ok(Article { title, url, html })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cx() -> Cx {
|
||||
Cx { account: 1, remote: "-100123".into(), message: 7, photos: HashMap::new(), documents: HashMap::new(), wanted: Vec::new() }
|
||||
}
|
||||
|
||||
fn plain_text(t: &str) -> R {
|
||||
R::TextPlain(tl::types::TextPlain { text: t.into() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_escaped_and_styled() {
|
||||
let mut c = cx();
|
||||
let t = R::TextConcat(tl::types::TextConcat {
|
||||
texts: vec![plain_text("a <b> & "), R::TextBold(Box::new(tl::types::TextBold { text: plain_text("bold") }))],
|
||||
});
|
||||
assert_eq!(rich(&t, &mut c), "a <b> & <b>bold</b>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_may_only_go_where_a_page_would() {
|
||||
assert_eq!(safe_url("https://example.com/a?b=1&c=2"), "https://example.com/a?b=1&c=2");
|
||||
assert_eq!(safe_url("javascript:alert(1)"), "#");
|
||||
assert_eq!(safe_url("data:text/html,x"), "#");
|
||||
assert_eq!(safe_url("#section"), "#section");
|
||||
let mut c = cx();
|
||||
let link = R::TextUrl(Box::new(tl::types::TextUrl { text: plain_text("x"), url: "javascript:alert(1)".into(), webpage_id: 0 }));
|
||||
assert_eq!(rich(&link, &mut c), "<a href=\"#\">x</a>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_the_page_lacks_keeps_its_words_and_one_it_has_is_named_by_its_file() {
|
||||
let mut c = cx();
|
||||
let photo = B::Photo(tl::types::PageBlockPhoto {
|
||||
spoiler: false,
|
||||
photo_id: 9,
|
||||
caption: tl::enums::PageCaption::Caption(tl::types::PageCaption { text: plain_text("a caption"), credit: R::TextEmpty }),
|
||||
url: None,
|
||||
webpage_id: None,
|
||||
});
|
||||
let without = block(&photo, &mut c);
|
||||
assert!(!without.contains("<img") && without.contains("a caption"), "{without}");
|
||||
c.photos.insert(9, tl::enums::Photo::Empty(tl::types::PhotoEmpty { id: 9 }));
|
||||
let html = block(&photo, &mut c);
|
||||
assert!(html.contains("moho-media://file/?p=") && html.contains("_iv9.jpg") && html.contains("a caption"), "{html}");
|
||||
assert_eq!(c.wanted, vec![Want::Photo(9)]);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! One connection to Telegram: the library's sender pool, its client and the stream of what Telegram sends.
|
||||
//!
|
||||
//! Everything that talks to Telegram - a sign-in, a connected account - opens one of these and closes it when it
|
||||
//! is done. The pool connects to each datacenter on demand, and a closed one drops its connections gracefully.
|
||||
|
||||
use super::session::FileSession;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{Context, Result};
|
||||
use grammers_client::Client;
|
||||
use grammers_mtsender::{SenderPool, SenderPoolFatHandle};
|
||||
use grammers_session::updates::UpdatesLike;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Link {
|
||||
pub client: Client,
|
||||
pub session: Arc<FileSession>,
|
||||
/// What Telegram sends unasked, to be handed to `Client::stream_updates` once (taken by whoever reads it).
|
||||
pub updates: Option<Receiver<UpdatesLike>>,
|
||||
handle: SenderPoolFatHandle,
|
||||
runner: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Opens a connection under `key` (the account's id, or a sign-in's pending key), by the account's route if it has
|
||||
/// one: Tor or the proxy, as a SOCKS5 address the library connects through.
|
||||
pub async fn open(state: &AppState, key: &str, api_id: i32, session: Arc<FileSession>) -> Result<Link> {
|
||||
let router = crate::net::route::router();
|
||||
let proxy = if router.routed(key) {
|
||||
let (host, port) = router.ready(|msg| state.runtime.report_progress(state, key, msg)).await.context("starting Tor or reaching the proxy")?;
|
||||
Some(format!("socks5://{host}:{port}"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let SenderPool { runner, handle, updates } = SenderPool::with_configuration(session.clone(), api_id, super::connection_params(proxy.as_deref()));
|
||||
let client = Client::new(handle.clone());
|
||||
let runner = tokio::spawn(runner.run());
|
||||
Ok(Link { client, session, updates: Some(updates), handle, runner })
|
||||
}
|
||||
|
||||
impl Link {
|
||||
/// Closes the connections, and writes the session if anything changed.
|
||||
pub async fn close(self) {
|
||||
self.handle.quit();
|
||||
let _ = self.runner.await;
|
||||
if let Err(e) = self.session.save_if_dirty() {
|
||||
tracing::warn!("telegram: saving the session: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
//! Adding an account: the sign-in a person takes part in.
|
||||
//!
|
||||
//! The person types an `api_id`, an `api_hash` and a phone number. Telegram sends a code to the account's other
|
||||
//! sessions (or by SMS), the person types it, and if the account has a cloud password that is asked for next.
|
||||
//! Neither the code nor the password is kept: what the account keeps is the session Telegram issued.
|
||||
//!
|
||||
//! Like Steam's, it is a conversation that can take minutes, so it runs as its own task and talks to the window
|
||||
//! through events:
|
||||
//!
|
||||
//! - `telegramLoginStatus` - what it is doing, for a line of text
|
||||
//! - `telegramLoginNeeds` - what Telegram is asking for: `step` is `code` or `password` (with the password's hint),
|
||||
//! and `error` says why the last answer was refused
|
||||
//! - `telegramLoginResult` - done, with the account, or the reason it failed
|
||||
//!
|
||||
//! and the window answers with `submitTelegramCode` and `submitTelegramPassword`.
|
||||
|
||||
use super::link::{self, Link};
|
||||
use super::session::{self, FileSession};
|
||||
use crate::accounts::TelegramAccountConfig;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use grammers_client::SignInError;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// How long a sign-in may wait for a person. Telegram's own code is good for a few minutes, and a form left open
|
||||
/// overnight should not leave a task.
|
||||
const PATIENCE: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// What the window can hand to a sign-in that is waiting.
|
||||
enum Answer {
|
||||
Code(String),
|
||||
Password(String),
|
||||
}
|
||||
|
||||
/// The sign-ins waiting for an answer, by id, and the way to give each one.
|
||||
fn waiting() -> &'static Mutex<HashMap<String, mpsc::UnboundedSender<Answer>>> {
|
||||
static WAITING: std::sync::OnceLock<Mutex<HashMap<String, mpsc::UnboundedSender<Answer>>>> = std::sync::OnceLock::new();
|
||||
WAITING.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
fn give(login_id: &str, answer: Answer) -> Result<()> {
|
||||
let sender = waiting().lock().unwrap().get(login_id).cloned().ok_or_else(|| anyhow!("that sign-in is not waiting for anything any more"))?;
|
||||
sender.send(answer).map_err(|_| anyhow!("that sign-in has ended"))
|
||||
}
|
||||
|
||||
/// Gives a sign-in the code Telegram sent.
|
||||
pub fn submit_code(login_id: &str, code: &str) -> Result<()> {
|
||||
// Telegram's own client shows the code as "12345"; a pasted one may carry spaces or a dash.
|
||||
let code: String = code.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
if code.is_empty() {
|
||||
bail!("type the code first");
|
||||
}
|
||||
give(login_id, Answer::Code(code))
|
||||
}
|
||||
|
||||
/// Gives a sign-in the account's cloud password.
|
||||
pub fn submit_password(login_id: &str, password: &str) -> Result<()> {
|
||||
if password.is_empty() {
|
||||
bail!("type the password first");
|
||||
}
|
||||
give(login_id, Answer::Password(password.to_string()))
|
||||
}
|
||||
|
||||
/// Stops a sign-in that is waiting, because the form was closed.
|
||||
pub fn cancel(login_id: &str) {
|
||||
waiting().lock().unwrap().remove(login_id);
|
||||
}
|
||||
|
||||
/// A phone number as Telegram wants it: digits with a `+` in front.
|
||||
pub fn normalize_phone(typed: &str) -> Option<String> {
|
||||
let digits: String = typed.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
// The shortest numbers in use are about seven digits and the longest fifteen.
|
||||
(7..=15).contains(&digits.len()).then(|| format!("+{digits}"))
|
||||
}
|
||||
|
||||
/// Starts a sign-in. Answers at once; the rest is events.
|
||||
pub fn start(state: AppState, login_id: String, api_id: i32, api_hash: String, phone: String) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
waiting().lock().unwrap().insert(login_id.clone(), tx);
|
||||
tokio::spawn(async move {
|
||||
let outcome = flow(&state, &login_id, api_id, &api_hash, &phone, rx).await;
|
||||
waiting().lock().unwrap().remove(&login_id);
|
||||
match outcome {
|
||||
Ok(Some(saved)) => {
|
||||
let account = crate::accounts::telegram_account_to_json(&saved, "connecting");
|
||||
super::connect::spawn(state.clone(), saved);
|
||||
state.events.emit("telegramLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
|
||||
}
|
||||
// The form was closed; nobody is waiting for an answer.
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let error = format!("{e:#}");
|
||||
tracing::info!("telegram: sign-in {login_id}: {error}");
|
||||
state.events.emit("telegramLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn status(state: &AppState, login_id: &str, text: &str) {
|
||||
state.events.emit("telegramLoginStatus", json!({ "loginId": login_id, "text": text }));
|
||||
}
|
||||
|
||||
fn needs(state: &AppState, login_id: &str, step: &str, hint: Option<&str>, error: Option<&str>) {
|
||||
state.events.emit("telegramLoginNeeds", json!({ "loginId": login_id, "step": step, "hint": hint, "error": error }));
|
||||
}
|
||||
|
||||
/// What was typed, or `None` when the form was closed or nobody answered in time.
|
||||
async fn answer(rx: &mut mpsc::UnboundedReceiver<Answer>) -> Option<Answer> {
|
||||
tokio::time::timeout(PATIENCE, rx.recv()).await.ok().flatten()
|
||||
}
|
||||
|
||||
async fn flow(state: &AppState, login_id: &str, api_id: i32, api_hash: &str, phone: &str, mut rx: mpsc::UnboundedReceiver<Answer>) -> Result<Option<TelegramAccountConfig>> {
|
||||
if api_id <= 0 || api_hash.trim().is_empty() {
|
||||
bail!("an api_id and an api_hash are needed: get them at my.telegram.org/apps");
|
||||
}
|
||||
let phone = normalize_phone(phone).ok_or_else(|| anyhow!("that does not look like a phone number; type it with its country code"))?;
|
||||
status(state, login_id, "Connecting to Telegram");
|
||||
let session = Arc::new(FileSession::fresh(session::path_for_login(login_id)));
|
||||
let link = link::open(state, &crate::net::route::pending_key("telegram"), api_id, session.clone()).await?;
|
||||
let outcome = signed_in(state, login_id, &link, api_hash, &phone, &mut rx).await;
|
||||
let user = match outcome {
|
||||
Ok(Some(user)) => user,
|
||||
other => {
|
||||
link.close().await;
|
||||
session.delete();
|
||||
return other.map(|_| None);
|
||||
}
|
||||
};
|
||||
let config = TelegramAccountConfig {
|
||||
api_id,
|
||||
api_hash: api_hash.trim().to_string(),
|
||||
phone,
|
||||
user_id: user.id().bare_id_unchecked(),
|
||||
username: user.username().map(str::to_string),
|
||||
name: user.full_name(),
|
||||
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("telegram")),
|
||||
..Default::default()
|
||||
};
|
||||
// The session is this account's now: under its own name, and written before the connection that made it closes.
|
||||
session.move_to(session::path_for(config.user_id)).context("saving the session")?;
|
||||
link.close().await;
|
||||
let saved = state.accounts.add_telegram(config)?;
|
||||
Ok(Some(saved))
|
||||
}
|
||||
|
||||
/// The conversation with Telegram, up to being signed in.
|
||||
async fn signed_in(state: &AppState, login_id: &str, link: &Link, api_hash: &str, phone: &str, rx: &mut mpsc::UnboundedReceiver<Answer>) -> Result<Option<grammers_client::peer::User>> {
|
||||
let client = &link.client;
|
||||
if client.is_authorized().await.context("asking Telegram whether this session is signed in")? {
|
||||
bail!("this session is already signed in");
|
||||
}
|
||||
status(state, login_id, "Asking Telegram to send a code");
|
||||
let token = client.request_login_code(phone, api_hash).await.map_err(|e| anyhow!("{}", explain(&e.to_string())))?;
|
||||
needs(state, login_id, "code", None, None);
|
||||
loop {
|
||||
let Some(given) = answer(rx).await else { return Ok(None) };
|
||||
match given {
|
||||
Answer::Code(code) => match client.sign_in(&token, &code).await {
|
||||
Ok(user) => return Ok(Some(user)),
|
||||
Err(SignInError::PasswordRequired(password)) => {
|
||||
let hint = password.hint().map(str::to_string);
|
||||
needs(state, login_id, "password", hint.as_deref(), None);
|
||||
return ask_password(state, login_id, client, password, rx).await;
|
||||
}
|
||||
Err(SignInError::InvalidCode) => needs(state, login_id, "code", None, Some("That code is not right. Check it and try again")),
|
||||
Err(SignInError::SignUpRequired) => bail!("this number has no Telegram account. Make one with Telegram's own app first"),
|
||||
Err(e) => bail!("{}", explain(&e.to_string())),
|
||||
},
|
||||
Answer::Password(_) => needs(state, login_id, "code", None, Some("Telegram has not asked for a password")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ask_password(state: &AppState, login_id: &str, client: &grammers_client::Client, mut token: grammers_client::client::PasswordToken, rx: &mut mpsc::UnboundedReceiver<Answer>) -> Result<Option<grammers_client::peer::User>> {
|
||||
loop {
|
||||
let Some(given) = answer(rx).await else { return Ok(None) };
|
||||
let Answer::Password(password) = given else { continue };
|
||||
match client.check_password(token, password.as_bytes()).await {
|
||||
Ok(user) => return Ok(Some(user)),
|
||||
Err(SignInError::InvalidPassword(again)) => {
|
||||
needs(state, login_id, "password", again.hint(), Some("That password is not right"));
|
||||
token = again;
|
||||
}
|
||||
Err(e) => bail!("{}", explain(&e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram's own wording for what went wrong, said so a person can act on it.
|
||||
pub fn explain(raw: &str) -> String {
|
||||
let upper = raw.to_uppercase();
|
||||
for (code, words) in [
|
||||
("API_ID_INVALID", "Telegram does not accept that api_id and api_hash pair. Check them at my.telegram.org/apps"),
|
||||
("PHONE_NUMBER_INVALID", "Telegram says that is not a valid phone number"),
|
||||
("PHONE_NUMBER_BANNED", "Telegram has banned that phone number"),
|
||||
("PHONE_CODE_EXPIRED", "That code has expired. Start again to get a new one"),
|
||||
("PHONE_CODE_INVALID", "That code is not right"),
|
||||
("PASSWORD_HASH_INVALID", "That password is not right"),
|
||||
("FLOOD_WAIT", "Telegram says too many attempts were made. Wait before trying again"),
|
||||
("SESSION_PASSWORD_NEEDED", "This account has a cloud password"),
|
||||
("AUTH_KEY_UNREGISTERED", "Telegram has ended this session"),
|
||||
("USER_DEACTIVATED", "Telegram says this account has been deleted"),
|
||||
] {
|
||||
if upper.contains(code) {
|
||||
return words.to_string();
|
||||
}
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_phone_number_is_made_the_way_telegram_wants_it() {
|
||||
assert_eq!(normalize_phone("+1 (555) 123-4567").as_deref(), Some("+15551234567"));
|
||||
assert_eq!(normalize_phone("44 20 7946 0958").as_deref(), Some("+442079460958"));
|
||||
assert_eq!(normalize_phone("12345"), None);
|
||||
assert_eq!(normalize_phone("not a number"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_for_a_sign_in_that_is_not_waiting_are_refused_in_words() {
|
||||
assert!(submit_code("telegram-none", "12345").unwrap_err().to_string().contains("not waiting"));
|
||||
assert!(submit_code("telegram-none", "abc").unwrap_err().to_string().contains("type the code"));
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
waiting().lock().unwrap().insert("telegram-t".into(), tx);
|
||||
submit_code("telegram-t", "1 2-3 4 5").unwrap();
|
||||
submit_password("telegram-t", "hunter2").unwrap();
|
||||
assert!(matches!(rx.try_recv(), Ok(Answer::Code(c)) if c == "12345"), "spaces and dashes in a pasted code are dropped");
|
||||
assert!(matches!(rx.try_recv(), Ok(Answer::Password(p)) if p == "hunter2"));
|
||||
cancel("telegram-t");
|
||||
assert!(submit_code("telegram-t", "12345").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegrams_refusals_are_said_so_a_person_can_act_on_them() {
|
||||
assert!(explain("rpc error 400: API_ID_INVALID").contains("my.telegram.org"));
|
||||
assert!(explain("PHONE_CODE_EXPIRED").contains("expired"));
|
||||
assert_eq!(explain("something unforeseen"), "something unforeseen");
|
||||
}
|
||||
|
||||
/// Against Telegram's own test servers, which have no real accounts: a number `99966XYYYY` signs in with the code
|
||||
/// `XXXXX` (its digit X, five times). Needs the person's `api_id` and `api_hash` (the test servers take a real pair):
|
||||
///
|
||||
/// `TELEGRAM_LIVE_API_ID=... TELEGRAM_LIVE_API_HASH=... TELEGRAM_LIVE_PHONE=+99966XYYYY cargo test --release live_ -- --ignored --nocapture`
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_a_test_number_signs_in_and_out_on_telegrams_test_servers() {
|
||||
use grammers_session::types::DcOption;
|
||||
use grammers_session::Session;
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let api_id: i32 = std::env::var("TELEGRAM_LIVE_API_ID").unwrap().parse().unwrap();
|
||||
let api_hash = std::env::var("TELEGRAM_LIVE_API_HASH").unwrap();
|
||||
let phone = normalize_phone(&std::env::var("TELEGRAM_LIVE_PHONE").unwrap()).unwrap();
|
||||
assert!(phone.starts_with("+99966"), "only a test number, which has no real account behind it");
|
||||
let code: String = std::iter::repeat_n(phone.chars().nth(6).unwrap(), 5).collect();
|
||||
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("telegram-live");
|
||||
let path = std::env::temp_dir().join(format!("nobilis-telegram-live-{}.session", std::process::id()));
|
||||
let session = Arc::new(FileSession::fresh(path.clone()));
|
||||
// Telegram's test datacenter 2 stands where the real one does.
|
||||
let mut dc: DcOption = session.dc_option(2).unwrap().unwrap();
|
||||
dc.ipv4 = "149.154.167.40:443".parse().unwrap();
|
||||
dc.ipv6 = "[::ffff:149.154.167.40]:443".parse().unwrap();
|
||||
session.set_dc_option(&dc).await.unwrap();
|
||||
session.set_home_dc_id(2).await.unwrap();
|
||||
|
||||
let link = link::open(&state, "telegram:live-test", api_id, session.clone()).await.unwrap();
|
||||
assert!(!link.client.is_authorized().await.unwrap());
|
||||
let token = link.client.request_login_code(&phone, &api_hash).await.unwrap();
|
||||
let user = match link.client.sign_in(&token, &code).await {
|
||||
Ok(user) => user,
|
||||
Err(e) => panic!("{}", explain(&e.to_string())),
|
||||
};
|
||||
println!("signed in as {} ({})", user.full_name(), user.id().bare_id_unchecked());
|
||||
assert!(link.client.is_authorized().await.unwrap());
|
||||
assert!(session.has_key());
|
||||
session.save().unwrap();
|
||||
let reopened = FileSession::open(path.clone()).unwrap();
|
||||
assert!(reopened.has_key(), "the saved session still has its key");
|
||||
link.client.sign_out().await.unwrap();
|
||||
link.close().await;
|
||||
std::fs::remove_file(path).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
//! What the message box holds, as Telegram reads it: plain text and a list of entities.
|
||||
//!
|
||||
//! The window writes formatting as Markdown, the way Discord reads it (`**bold**`, `*italic*`, `__underline__`,
|
||||
//! `~~strike~~`, `||spoiler||`, `` `code` ``, fenced code, `> quote`, `[text](url)`), and Telegram wants the text with
|
||||
//! the marks taken out and a list saying where each kind starts and how long it runs, counted in UTF-16 code units.
|
||||
//! A mark with no partner is the character it looks like.
|
||||
|
||||
use grammers_client::tl;
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
enum Kind {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strike,
|
||||
Spoiler,
|
||||
Code,
|
||||
Pre,
|
||||
Quote,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Span {
|
||||
kind: Kind,
|
||||
offset: usize,
|
||||
length: usize,
|
||||
/// For a link, where it goes.
|
||||
url: Option<String>,
|
||||
/// For a fenced block, its language.
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Out {
|
||||
text: String,
|
||||
units: usize,
|
||||
spans: Vec<Span>,
|
||||
}
|
||||
|
||||
impl Out {
|
||||
fn push(&mut self, c: char) {
|
||||
self.text.push(c);
|
||||
self.units += c.len_utf16();
|
||||
}
|
||||
fn push_str(&mut self, s: &str) {
|
||||
for c in s.chars() {
|
||||
self.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text with its marks taken out, and the entities that say what they marked.
|
||||
pub fn parse(source: &str) -> (String, Vec<tl::enums::MessageEntity>) {
|
||||
let mut out = Out::default();
|
||||
let lines: Vec<&str> = source.split('\n').collect();
|
||||
let mut i = 0;
|
||||
let mut first = true;
|
||||
while i < lines.len() {
|
||||
if !first {
|
||||
out.push('\n');
|
||||
}
|
||||
first = false;
|
||||
let line = lines[i];
|
||||
if let Some(fence) = line.strip_prefix("```") {
|
||||
// A fenced block runs to the next fence, or to the end.
|
||||
if let Some(end) = lines[i + 1..].iter().position(|l| l.trim_end() == "```") {
|
||||
let language = fence.trim().to_string();
|
||||
let body = lines[i + 1..i + 1 + end].join("\n");
|
||||
let offset = out.units;
|
||||
out.push_str(&body);
|
||||
out.spans.push(Span { kind: Kind::Pre, offset, length: out.units - offset, url: None, language: (!language.is_empty()).then_some(language) });
|
||||
i += end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if line.starts_with("> ") || line == ">" {
|
||||
// Consecutive quoted lines are one quote.
|
||||
let start = out.units;
|
||||
let mut quoted = vec![line.strip_prefix("> ").unwrap_or("")];
|
||||
while i + 1 < lines.len() && (lines[i + 1].starts_with("> ") || lines[i + 1] == ">") {
|
||||
i += 1;
|
||||
quoted.push(lines[i].strip_prefix("> ").unwrap_or(""));
|
||||
}
|
||||
for (n, q) in quoted.iter().enumerate() {
|
||||
if n > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
inline(&q.chars().collect::<Vec<_>>(), &mut out);
|
||||
}
|
||||
out.spans.push(Span { kind: Kind::Quote, offset: start, length: out.units - start, url: None, language: None });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
inline(&line.chars().collect::<Vec<_>>(), &mut out);
|
||||
i += 1;
|
||||
}
|
||||
let entities = out.spans.iter().filter(|s| s.length > 0).map(entity).collect();
|
||||
(out.text, entities)
|
||||
}
|
||||
|
||||
fn entity(s: &Span) -> tl::enums::MessageEntity {
|
||||
use tl::enums::MessageEntity as E;
|
||||
use tl::types as t;
|
||||
let (offset, length) = (s.offset as i32, s.length as i32);
|
||||
if let Some(url) = &s.url {
|
||||
return E::TextUrl(t::MessageEntityTextUrl { offset, length, url: url.clone() });
|
||||
}
|
||||
match s.kind {
|
||||
Kind::Bold => E::Bold(t::MessageEntityBold { offset, length }),
|
||||
Kind::Italic => E::Italic(t::MessageEntityItalic { offset, length }),
|
||||
Kind::Underline => E::Underline(t::MessageEntityUnderline { offset, length }),
|
||||
Kind::Strike => E::Strike(t::MessageEntityStrike { offset, length }),
|
||||
Kind::Spoiler => E::Spoiler(t::MessageEntitySpoiler { offset, length }),
|
||||
Kind::Code => E::Code(t::MessageEntityCode { offset, length }),
|
||||
Kind::Pre => E::Pre(t::MessageEntityPre { offset, length, language: s.language.clone().unwrap_or_default() }),
|
||||
Kind::Quote => E::Blockquote(t::MessageEntityBlockquote { collapsed: false, offset, length }),
|
||||
}
|
||||
}
|
||||
|
||||
fn find(chars: &[char], from: usize, marker: &[char]) -> Option<usize> {
|
||||
(from..chars.len().saturating_sub(marker.len() - 1)).find(|&j| chars[j..].starts_with(marker))
|
||||
}
|
||||
|
||||
/// A single `*` or `_` only opens at the start of a word and closes at the end of one, so `snake_case_name` and `2*3*4`
|
||||
/// stay as written.
|
||||
fn word_edge(chars: &[char], at: usize, opening: bool) -> bool {
|
||||
let alnum = |c: char| c.is_alphanumeric();
|
||||
if opening {
|
||||
(at == 0 || !alnum(chars[at - 1])) && chars.get(at + 1).is_some_and(|c| !c.is_whitespace())
|
||||
} else {
|
||||
at > 0 && !chars[at - 1].is_whitespace() && chars.get(at + 1).is_none_or(|c| !alnum(*c))
|
||||
}
|
||||
}
|
||||
|
||||
fn inline(chars: &[char], out: &mut Out) {
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c == '\\' && i + 1 < chars.len() && "\\*_~|`[]()>".contains(chars[i + 1]) {
|
||||
out.push(chars[i + 1]);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Code takes its contents as they are.
|
||||
if c == '`' {
|
||||
if let Some(end) = find(chars, i + 1, &['`']).filter(|&e| e > i + 1) {
|
||||
let offset = out.units;
|
||||
out.push_str(&chars[i + 1..end].iter().collect::<String>());
|
||||
out.spans.push(Span { kind: Kind::Code, offset, length: out.units - offset, url: None, language: None });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// [text](url)
|
||||
if c == '[' {
|
||||
if let Some(close) = find(chars, i + 1, &[']']) {
|
||||
if chars.get(close + 1) == Some(&'(') {
|
||||
if let Some(end) = find(chars, close + 2, &[')']) {
|
||||
let url: String = chars[close + 2..end].iter().collect();
|
||||
if ["http://", "https://", "tg://", "mailto:"].iter().any(|p| url.starts_with(p)) && close > i + 1 {
|
||||
let offset = out.units;
|
||||
inline(&chars[i + 1..close], out);
|
||||
out.spans.push(Span { kind: Kind::Bold, offset, length: out.units - offset, url: Some(url), language: None });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let two = |m: &str| m.chars().collect::<Vec<_>>();
|
||||
let pairs: [(&str, Kind); 4] = [("**", Kind::Bold), ("__", Kind::Underline), ("~~", Kind::Strike), ("||", Kind::Spoiler)];
|
||||
let mut done = false;
|
||||
for (marker, kind) in pairs {
|
||||
let m = two(marker);
|
||||
if chars[i..].starts_with(&m) {
|
||||
if let Some(end) = find(chars, i + 2, &m).filter(|&e| e > i + 2) {
|
||||
let offset = out.units;
|
||||
inline(&chars[i + 2..end], out);
|
||||
out.spans.push(Span { kind, offset, length: out.units - offset, url: None, language: None });
|
||||
i = end + 2;
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
continue;
|
||||
}
|
||||
if (c == '*' || c == '_') && word_edge(chars, i, true) {
|
||||
// The closing one: the next of the same mark that ends a word.
|
||||
let mut j = i + 1;
|
||||
let mut found = None;
|
||||
while let Some(e) = find(chars, j, &[c]) {
|
||||
if e > i + 1 && word_edge(chars, e, false) {
|
||||
found = Some(e);
|
||||
break;
|
||||
}
|
||||
j = e + 1;
|
||||
}
|
||||
if let Some(end) = found {
|
||||
let offset = out.units;
|
||||
inline(&chars[i + 1..end], out);
|
||||
out.spans.push(Span { kind: Kind::Italic, offset, length: out.units - offset, url: None, language: None });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tl::enums::MessageEntity as E;
|
||||
|
||||
fn kinds(entities: &[E]) -> Vec<(&'static str, i32, i32)> {
|
||||
entities
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
E::Bold(x) => ("bold", x.offset, x.length),
|
||||
E::Italic(x) => ("italic", x.offset, x.length),
|
||||
E::Underline(x) => ("underline", x.offset, x.length),
|
||||
E::Strike(x) => ("strike", x.offset, x.length),
|
||||
E::Spoiler(x) => ("spoiler", x.offset, x.length),
|
||||
E::Code(x) => ("code", x.offset, x.length),
|
||||
E::Pre(x) => ("pre", x.offset, x.length),
|
||||
E::Blockquote(x) => ("quote", x.offset, x.length),
|
||||
E::TextUrl(x) => ("link", x.offset, x.length),
|
||||
_ => ("other", 0, 0),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_are_taken_out_and_the_entities_say_where_they_were() {
|
||||
let (text, entities) = parse("a **bold** and *italic* and ~~gone~~ and ||secret|| and __under__");
|
||||
assert_eq!(text, "a bold and italic and gone and secret and under");
|
||||
let got = kinds(&entities);
|
||||
assert!(got.contains(&("bold", 2, 4)) && got.contains(&("italic", 11, 6)) && got.contains(&("strike", 22, 4)) && got.contains(&("spoiler", 31, 6)) && got.contains(&("underline", 42, 5)), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_nest_and_a_mark_with_no_partner_is_just_the_character() {
|
||||
let (text, entities) = parse("**a *b* c**");
|
||||
assert_eq!(text, "a b c");
|
||||
let got = kinds(&entities);
|
||||
assert!(got.contains(&("bold", 0, 5)) && got.contains(&("italic", 2, 1)), "{got:?}");
|
||||
let (text, entities) = parse("2 * 3 and snake_case_name and **unclosed");
|
||||
assert_eq!(text, "2 * 3 and snake_case_name and **unclosed");
|
||||
assert!(entities.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_is_taken_as_written_and_a_fence_is_a_block_with_its_language() {
|
||||
let (text, entities) = parse("see `**not bold**` ok");
|
||||
assert_eq!(text, "see **not bold** ok");
|
||||
assert_eq!(kinds(&entities), vec![("code", 4, 12)]);
|
||||
let (text, entities) = parse("```rust\nlet x = 1;\n```\nafter");
|
||||
assert_eq!(text, "let x = 1;\nafter");
|
||||
assert!(matches!(&entities[0], E::Pre(p) if p.language == "rust" && p.offset == 0 && p.length == 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_and_a_quote_and_an_escape() {
|
||||
let (text, entities) = parse("[here](https://example.com/a?b=1) \\*not\\*");
|
||||
assert_eq!(text, "here *not*");
|
||||
assert!(matches!(&entities[0], E::TextUrl(u) if u.url == "https://example.com/a?b=1" && u.offset == 0 && u.length == 4));
|
||||
let (text, entities) = parse("> one\n> two\nthree");
|
||||
assert_eq!(text, "one\ntwo\nthree");
|
||||
assert_eq!(kinds(&entities), vec![("quote", 0, 7)]);
|
||||
// A link to somewhere that is not a web address is left as it was typed.
|
||||
assert_eq!(parse("[x](javascript:alert(1))").0, "[x](javascript:alert(1))");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_count_utf16_units_so_an_emoji_before_a_mark_moves_it_by_two() {
|
||||
let (text, entities) = parse("😀 **hi**");
|
||||
assert_eq!(text, "😀 hi");
|
||||
assert_eq!(kinds(&entities), vec![("bold", 3, 2)]);
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
//! Pictures, video, files, voice and stickers: what a message carries, and fetching it.
|
||||
//!
|
||||
//! Nothing is fetched until it is looked at. A message is recorded with attachments that name files in the daemon's
|
||||
//! caches (`telegram-media`, `telegram-avatars`) - files that need not exist yet - and when the window asks for one
|
||||
//! that is not there, the "restore a cached picture" route it already has for every other service asks the daemon,
|
||||
//! which finds the file's message again (a file reference expires, so the one remembered would not do), downloads it
|
||||
//! and answers. So opening a channel of fifty posts fetches the pictures on screen, not fifty videos.
|
||||
//!
|
||||
//! A cached file is named for what it is: `tg_<account>_<conversation>_<message>_<thumb|full>.<ext>` for a message's
|
||||
//! media, `tg_<account>_<person>_<photo>.jpg` for a profile picture.
|
||||
|
||||
use super::peers;
|
||||
use super::Handle;
|
||||
use crate::model::{Attachment, Embed};
|
||||
use crate::state::AppState;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use grammers_client::media::{Document, Downloadable, Media, PhotoSize};
|
||||
use grammers_client::message::Message;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerId;
|
||||
use grammers_session::Session;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const MEDIA: &str = "telegram-media";
|
||||
pub const AVATARS: &str = "telegram-avatars";
|
||||
|
||||
/// How many downloads run at once: enough for a screenful of pictures, few enough not to look like a scraper.
|
||||
const AT_ONCE: usize = 4;
|
||||
|
||||
/// How many whole files (videos, documents) are fetched at once. They take minutes, and pictures must not wait behind
|
||||
/// them: those have places of their own.
|
||||
const BIG_AT_ONCE: usize = 2;
|
||||
|
||||
fn permits(big: bool) -> &'static tokio::sync::Semaphore {
|
||||
static P: std::sync::OnceLock<tokio::sync::Semaphore> = std::sync::OnceLock::new();
|
||||
static BIG: std::sync::OnceLock<tokio::sync::Semaphore> = std::sync::OnceLock::new();
|
||||
if big {
|
||||
BIG.get_or_init(|| tokio::sync::Semaphore::new(BIG_AT_ONCE))
|
||||
} else {
|
||||
P.get_or_init(|| tokio::sync::Semaphore::new(AT_ONCE))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Variant {
|
||||
/// A small picture to draw in the list.
|
||||
Thumb,
|
||||
/// The picture at the size the viewer shows, or the file itself.
|
||||
Full,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
fn word(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Thumb => "thumb",
|
||||
Variant::Full => "full",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A conversation's id as a piece of a file name: a group's `-` would be taken for a separator.
|
||||
fn slug(remote: &str) -> String {
|
||||
remote.replace('-', "n")
|
||||
}
|
||||
|
||||
fn unslug(text: &str) -> String {
|
||||
match text.strip_prefix('n') {
|
||||
Some(rest) => format!("-{rest}"),
|
||||
None => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn media_name(account: i64, remote: &str, message: i32, variant: Variant, ext: &str) -> String {
|
||||
format!("tg_{account}_{}_{message}_{}.{ext}", slug(remote), variant.word())
|
||||
}
|
||||
|
||||
pub fn avatar_name(account: i64, remote: &str, photo: i64) -> String {
|
||||
format!("tg_{account}_{}_{photo}.jpg", slug(remote))
|
||||
}
|
||||
|
||||
/// What a cached media file's name says: the account, the conversation, the message and which version of it.
|
||||
pub fn parse_media_name(name: &str) -> Option<(i64, String, i32, Variant)> {
|
||||
let stem = name.rsplit_once('.')?.0;
|
||||
let mut parts = stem.strip_prefix("tg_")?.split('_');
|
||||
let account = parts.next()?.parse().ok()?;
|
||||
let remote = unslug(parts.next()?);
|
||||
let message = parts.next()?.parse().ok()?;
|
||||
let variant = match parts.next()? {
|
||||
"thumb" => Variant::Thumb,
|
||||
"full" => Variant::Full,
|
||||
_ => return None,
|
||||
};
|
||||
parts.next().is_none().then_some((account, remote, message, variant))
|
||||
}
|
||||
|
||||
/// What a cached avatar's name says: the account, the person or chat, and which picture of theirs.
|
||||
pub fn parse_avatar_name(name: &str) -> Option<(i64, String, i64)> {
|
||||
let stem = name.strip_suffix(".jpg")?;
|
||||
let mut parts = stem.strip_prefix("tg_")?.split('_');
|
||||
let account = parts.next()?.parse().ok()?;
|
||||
let remote = unslug(parts.next()?);
|
||||
let photo = parts.next()?.parse().ok()?;
|
||||
parts.next().is_none().then_some((account, remote, photo))
|
||||
}
|
||||
|
||||
fn local(dir: &str, name: &str) -> String {
|
||||
format!("file://{}", crate::media_cache::dir(dir).join(name).display())
|
||||
}
|
||||
|
||||
/// The file's extension, from its name or else its type.
|
||||
pub fn extension_of(name: Option<&str>, mime: Option<&str>) -> String {
|
||||
if let Some(ext) = name.and_then(|n| n.rsplit_once('.')).map(|(_, e)| e.to_ascii_lowercase()).filter(|e| !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())) {
|
||||
return ext;
|
||||
}
|
||||
match mime.unwrap_or("") {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"video/mp4" => "mp4",
|
||||
"video/webm" => "webm",
|
||||
"video/quicktime" => "mov",
|
||||
"audio/ogg" | "audio/opus" => "ogg",
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/mp4" | "audio/m4a" => "m4a",
|
||||
"application/pdf" => "pdf",
|
||||
"application/zip" => "zip",
|
||||
"application/x-tgsticker" => "tgs",
|
||||
"text/plain" => "txt",
|
||||
_ => "bin",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The broad shape of a document, which decides how the window draws it.
|
||||
fn kind_of_mime(mime: &str) -> &'static str {
|
||||
if mime.starts_with("image/") && mime != "image/gif" {
|
||||
"image"
|
||||
} else if mime.starts_with("video/") || mime == "image/gif" {
|
||||
"video"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"audio"
|
||||
} else {
|
||||
"file"
|
||||
}
|
||||
}
|
||||
|
||||
fn dimensions(size: &PhotoSize) -> Option<(i32, i32)> {
|
||||
match size {
|
||||
PhotoSize::Size(s) => Some((s.width, s.height)),
|
||||
PhotoSize::Cached(s) => Some((s.width, s.height)),
|
||||
PhotoSize::Progressive(s) => Some((s.width, s.height)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version of a photo to draw in the list (about 320 wide) and the one to show in the viewer (about 1280).
|
||||
pub(super) fn pick(sizes: &[PhotoSize], target: i32) -> Option<&PhotoSize> {
|
||||
let sized: Vec<(&PhotoSize, i32)> = sizes.iter().filter_map(|s| dimensions(s).map(|(w, h)| (s, w.max(h)))).collect();
|
||||
choose(&sized, target).copied()
|
||||
}
|
||||
|
||||
/// The smallest that reaches the target, else the largest there is.
|
||||
fn choose<T>(items: &[(T, i32)], target: i32) -> Option<&T> {
|
||||
let mut by_size: Vec<&(T, i32)> = items.iter().collect();
|
||||
by_size.sort_by_key(|(_, longest)| *longest);
|
||||
by_size.iter().find(|(_, longest)| *longest >= target).or(by_size.last()).map(|(item, _)| item)
|
||||
}
|
||||
|
||||
/// The width a link card's picture is fetched at: it is drawn larger than a list thumbnail.
|
||||
const CARD: i32 = 800;
|
||||
|
||||
/// The picture of the web page a message links to, if the page has one.
|
||||
fn page_photo(media: &Media) -> Option<grammers_client::media::Photo> {
|
||||
let Media::WebPage(page) = media else { return None };
|
||||
let tl::enums::WebPage::Page(page) = &page.raw.webpage else { return None };
|
||||
page.photo.clone().map(grammers_client::media::Photo::from_raw)
|
||||
}
|
||||
|
||||
/// The link preview a message carries (what Telegram calls a web page, with or without an Instant View): a card with
|
||||
/// the site, title, description and picture. Its picture is named in the cache and fetched like any preview.
|
||||
pub fn embeds_of(account: i64, remote: &str, message: &Message) -> Vec<Embed> {
|
||||
let Some(media) = message.media() else { return Vec::new() };
|
||||
let Media::WebPage(page) = &media else { return Vec::new() };
|
||||
let tl::enums::WebPage::Page(page) = &page.raw.webpage else { return Vec::new() };
|
||||
let has_picture = page_photo(&media).is_some();
|
||||
if page.title.is_none() && page.description.is_none() && !has_picture {
|
||||
return Vec::new();
|
||||
}
|
||||
let non_empty = |t: &Option<String>| t.clone().filter(|t| !t.trim().is_empty());
|
||||
vec![Embed {
|
||||
title: non_empty(&page.title),
|
||||
description: non_empty(&page.description),
|
||||
url: Some(page.url.clone()),
|
||||
provider: non_empty(&page.site_name),
|
||||
author: non_empty(&page.author),
|
||||
image_url: has_picture.then(|| local(MEDIA, &media_name(account, remote, message.id(), Variant::Thumb, "jpg"))),
|
||||
// A page Telegram has made an article of opens as one: the card says so, as Telegram's own does.
|
||||
kind: page.cached_page.is_some().then(|| "instant".to_string()),
|
||||
cta: page.cached_page.is_some().then(|| crate::model::EmbedLink { label: "Instant View".to_string(), url: None }),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
/// What the message carries, as attachments naming files in the cache. Nothing is downloaded here.
|
||||
pub fn attachments_of(account: i64, remote: &str, message: &Message) -> Vec<Attachment> {
|
||||
let id = message.id();
|
||||
let Some(media) = message.media() else { return Vec::new() };
|
||||
let thumb = |ext: &str| Some(local(MEDIA, &media_name(account, remote, id, Variant::Thumb, ext)));
|
||||
let full = |ext: &str| Some(local(MEDIA, &media_name(account, remote, id, Variant::Full, ext)));
|
||||
match media {
|
||||
Media::Photo(photo) => {
|
||||
let sizes = photo.thumbs();
|
||||
let view = pick(&sizes, 1280).and_then(dimensions);
|
||||
vec![Attachment {
|
||||
kind: "image".into(),
|
||||
mimetype: Some("image/jpeg".into()),
|
||||
filename: Some(if photo.is_spoiler() { "SPOILER_photo.jpg".into() } else { "photo.jpg".into() }),
|
||||
size: photo.size().map(|s| s as u64),
|
||||
width: view.map(|(w, _)| w as u32),
|
||||
height: view.map(|(_, h)| h as u32),
|
||||
path: full("jpg"),
|
||||
thumbnail_path: thumb("jpg"),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
Media::Sticker(sticker) => {
|
||||
let document = sticker.document.clone();
|
||||
let mime = document.mime_type().unwrap_or("image/webp").to_string();
|
||||
let still = mime == "image/webp";
|
||||
let (w, h) = document.resolution().unwrap_or((512, 512));
|
||||
vec![Attachment {
|
||||
kind: "image".into(),
|
||||
mimetype: Some(if still { mime.clone() } else { "image/webp".into() }),
|
||||
filename: Some(format!("sticker{}", if sticker.emoji().is_empty() { String::new() } else { format!(" {}", sticker.emoji()) })),
|
||||
size: document.size().map(|s| s as u64),
|
||||
width: Some(w as u32),
|
||||
height: Some(h as u32),
|
||||
// An animated or video sticker is drawn as the still picture Telegram sends with it.
|
||||
path: if still { full(&extension_of(document.name(), Some(&mime))) } else { thumb("jpg") },
|
||||
thumbnail_path: thumb("jpg"),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
Media::Document(document) => {
|
||||
let mime = document.mime_type().unwrap_or("application/octet-stream").to_string();
|
||||
let name = document.name().map(str::to_string);
|
||||
let ext = extension_of(name.as_deref(), Some(&mime));
|
||||
let voice = is_voice(&document);
|
||||
let mut kind = kind_of_mime(&mime);
|
||||
if document.is_animated() && kind == "image" {
|
||||
kind = "video";
|
||||
}
|
||||
let (w, h) = document.resolution().map(|(w, h)| (Some(w as u32), Some(h as u32))).unwrap_or((None, None));
|
||||
vec![Attachment {
|
||||
kind: kind.into(),
|
||||
mimetype: Some(mime),
|
||||
filename: Some(match (&name, document.is_spoiler()) {
|
||||
(Some(n), true) => format!("SPOILER_{n}"),
|
||||
(Some(n), false) => n.clone(),
|
||||
(None, _) => format!("file.{ext}"),
|
||||
}),
|
||||
size: document.size().map(|s| s as u64),
|
||||
width: w,
|
||||
height: h,
|
||||
duration_secs: document.duration(),
|
||||
voice,
|
||||
path: full(&ext),
|
||||
thumbnail_path: (!document.thumbs().is_empty() && matches!(kind, "video" | "file" | "image")).then(|| local(MEDIA, &media_name(account, remote, id, Variant::Thumb, "jpg"))),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a recording was spoken into the app rather than attached as a file.
|
||||
fn is_voice(document: &Document) -> bool {
|
||||
match document.raw.document.as_ref() {
|
||||
Some(tl::enums::Document::Document(d)) => d.attributes.iter().any(|a| matches!(a, tl::enums::DocumentAttribute::Audio(audio) if audio.voice)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads to a file, whole and renamed into place so a half-written one is never taken for a picture.
|
||||
pub(super) async fn download_to<D: Downloadable>(client: &grammers_client::Client, what: &D, path: &Path, big: bool) -> Result<()> {
|
||||
let _permit = permits(big).acquire().await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
// Its own name for the half-written file: two fetches of one picture (a preview being fetched as the message was
|
||||
// recorded, and the window asking for it) must not write into the same one.
|
||||
static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let part = path.with_extension(format!("{}.part", SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
|
||||
if let Err(e) = client.download_media(what, &part).await {
|
||||
let _ = tokio::fs::remove_file(&part).await;
|
||||
return Err(anyhow!("{}", super::send::explain(&e)));
|
||||
}
|
||||
tokio::fs::rename(&part, path).await.with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches a message's media again and writes the version the name asks for.
|
||||
async fn fetch(handle: &Handle, remote: &str, message: i32, variant: Variant, path: &Path) -> Result<()> {
|
||||
let id = peers::peer_id_of(remote).ok_or_else(|| anyhow!("not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(id).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that conversation yet"))?;
|
||||
let found = handle.client.get_messages_by_id(peer, &[message]).await.map_err(|e| anyhow!("{}", super::send::explain(&e)))?;
|
||||
let Some(Some(message)) = found.into_iter().next() else { bail!("that message is no longer there") };
|
||||
match message.media().ok_or_else(|| anyhow!("that message has no media"))? {
|
||||
Media::Photo(photo) => {
|
||||
let sizes = photo.thumbs();
|
||||
let wanted = match variant {
|
||||
Variant::Thumb => pick(&sizes, 320),
|
||||
Variant::Full => pick(&sizes, 1280),
|
||||
};
|
||||
match wanted {
|
||||
Some(size) => download_to(&handle.client, size, path, false).await,
|
||||
None => download_to(&handle.client, &photo, path, false).await,
|
||||
}
|
||||
}
|
||||
Media::Sticker(sticker) => match variant {
|
||||
Variant::Full => download_to(&handle.client, &sticker.document, path, false).await,
|
||||
Variant::Thumb => download_thumb(&handle.client, &sticker.document, path).await,
|
||||
},
|
||||
Media::Document(document) => match variant {
|
||||
Variant::Full => download_to(&handle.client, &document, path, true).await,
|
||||
Variant::Thumb => download_thumb(&handle.client, &document, path).await,
|
||||
},
|
||||
media @ Media::WebPage(_) => {
|
||||
let photo = page_photo(&media).ok_or_else(|| anyhow!("that link has no picture"))?;
|
||||
match pick(&photo.thumbs(), CARD) {
|
||||
Some(size) => download_to(&handle.client, size, path, false).await,
|
||||
None => download_to(&handle.client, &photo, path, false).await,
|
||||
}
|
||||
}
|
||||
_ => bail!("that kind of media cannot be fetched"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn download_thumb(client: &grammers_client::Client, document: &Document, path: &Path) -> Result<()> {
|
||||
let sizes = document.thumbs();
|
||||
let size = pick(&sizes, 320).or(sizes.first()).ok_or_else(|| anyhow!("Telegram sent no preview of that"))?;
|
||||
// A tiny inline preview carries its own bytes; the rest are downloaded.
|
||||
if let Some(bytes) = Downloadable::to_data(size) {
|
||||
tokio::fs::write(path, bytes).await?;
|
||||
return Ok(());
|
||||
}
|
||||
download_to(client, size, path, false).await
|
||||
}
|
||||
|
||||
/// The previews being fetched now, so a message read twice at once fetches its preview once.
|
||||
fn fetching() -> &'static std::sync::Mutex<std::collections::HashSet<PathBuf>> {
|
||||
static F: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<PathBuf>>> = std::sync::OnceLock::new();
|
||||
F.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Fetches the small preview of a message's picture or video in the background, as the message is recorded.
|
||||
///
|
||||
/// Previews are a few kilobytes and are what the list draws, so they are not left to be asked for one by one when the
|
||||
/// window finds them missing (which asks Telegram for the message again each time). The message is in hand here, so
|
||||
/// nothing more is asked. Whole files are still fetched only when looked at.
|
||||
pub fn prefetch_preview(state: &AppState, account_id: &str, account: i64, remote: &str, message: &Message) {
|
||||
let Some(media) = message.media() else { return };
|
||||
let path = crate::media_cache::dir(MEDIA).join(media_name(account, remote, message.id(), Variant::Thumb, "jpg"));
|
||||
if path.exists() || !matches!(media, Media::Photo(_) | Media::Document(_) | Media::Sticker(_) | Media::WebPage(_)) {
|
||||
return;
|
||||
}
|
||||
if !fetching().lock().unwrap_or_else(|e| e.into_inner()).insert(path.clone()) {
|
||||
return;
|
||||
}
|
||||
let Some(handle) = state.runtime.telegram_handle(account_id) else {
|
||||
fetching().lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let done = match &media {
|
||||
Media::Photo(photo) => match pick(&photo.thumbs(), 320) {
|
||||
Some(size) => download_to(&handle.client, size, &path, false).await,
|
||||
None => download_to(&handle.client, photo, &path, false).await,
|
||||
},
|
||||
Media::Document(document) => download_thumb(&handle.client, document, &path).await,
|
||||
Media::Sticker(sticker) => download_thumb(&handle.client, &sticker.document, &path).await,
|
||||
Media::WebPage(_) => match page_photo(&media) {
|
||||
Some(photo) => match pick(&photo.thumbs(), CARD) {
|
||||
Some(size) => download_to(&handle.client, size, &path, false).await,
|
||||
None => download_to(&handle.client, &photo, &path, false).await,
|
||||
},
|
||||
None => Ok(()),
|
||||
},
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(e) = done {
|
||||
tracing::debug!("telegram: preview {}: {e:#}", path.display());
|
||||
}
|
||||
fetching().lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetches a cached Telegram file again, after it expired, was cleared, or was named before it was ever fetched.
|
||||
pub async fn restore_cached(state: &AppState, path: &Path) -> Result<PathBuf> {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).context("not a cached file")?;
|
||||
let parent = path.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()).unwrap_or("");
|
||||
if parent == AVATARS {
|
||||
let (account, peer, photo) = parse_avatar_name(name).context("not a cached Telegram picture")?;
|
||||
let handle = state.runtime.telegram_handle(&format!("telegram:{account}")).context("that Telegram account is not connected")?;
|
||||
fetch_avatar(&handle, &peer, photo, path).await?;
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
let (account, remote, message, variant) = parse_media_name(name).context("not a cached Telegram file")?;
|
||||
// A preview already on its way is waited for, not fetched a second time.
|
||||
for _ in 0..120 {
|
||||
if path.exists() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
if !fetching().lock().unwrap_or_else(|e| e.into_inner()).contains(path) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
}
|
||||
let handle = state.runtime.telegram_handle(&format!("telegram:{account}")).context("that Telegram account is not connected")?;
|
||||
fetch(&handle, &remote, message, variant, path).await?;
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// The file an avatar is cached as, for a person or chat with a profile photo; `None` for one without.
|
||||
pub fn avatar_url(account: i64, peer: &grammers_client::peer::Peer) -> Option<String> {
|
||||
use grammers_client::peer::Peer;
|
||||
let photo_id = match peer {
|
||||
Peer::User(u) => u.photo().map(|p| p.photo_id),
|
||||
Peer::Group(g) => g.photo().map(|p| p.photo_id),
|
||||
Peer::Channel(c) => c.photo().map(|p| p.photo_id),
|
||||
Peer::Community(_) => None,
|
||||
}?;
|
||||
Some(local(AVATARS, &avatar_name(account, &peers::remote_id(peer.id()), photo_id)))
|
||||
}
|
||||
|
||||
async fn fetch_avatar(handle: &Handle, remote: &str, photo_id: i64, path: &Path) -> Result<()> {
|
||||
let id: PeerId = peers::peer_id_of(remote).ok_or_else(|| anyhow!("not a Telegram conversation"))?;
|
||||
let peer = handle.session.peer_ref(id).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that picture's owner yet"))?;
|
||||
let what = grammers_client::media::ChatPhoto { raw: tl::types::InputPeerPhotoFileLocation { big: false, peer: peer.into(), photo_id }.into() };
|
||||
download_to(&handle.client, &what, path, false).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cached_files_name_says_what_it_is_and_is_read_back() {
|
||||
let name = media_name(5173409493, "-1001179270258", 21910, Variant::Full, "mp4");
|
||||
assert_eq!(name, "tg_5173409493_n1001179270258_21910_full.mp4");
|
||||
assert_eq!(parse_media_name(&name), Some((5173409493, "-1001179270258".to_string(), 21910, Variant::Full)));
|
||||
let person = media_name(1, "6576171495", 7, Variant::Thumb, "jpg");
|
||||
assert_eq!(parse_media_name(&person), Some((1, "6576171495".to_string(), 7, Variant::Thumb)));
|
||||
assert_eq!(parse_media_name("not_ours.jpg"), None);
|
||||
assert_eq!(parse_media_name("tg_1_n5_2_other.jpg"), None);
|
||||
let avatar = avatar_name(1, "-100123", 99);
|
||||
assert_eq!(parse_avatar_name(&avatar), Some((1, "-100123".to_string(), 99)));
|
||||
assert_eq!(parse_avatar_name("tg_1_n5_2_full.jpg"), None, "a media file is not an avatar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_files_extension_comes_from_its_name_else_its_type() {
|
||||
assert_eq!(extension_of(Some("Report.PDF"), None), "pdf");
|
||||
assert_eq!(extension_of(None, Some("video/mp4")), "mp4");
|
||||
assert_eq!(extension_of(Some("noextension"), Some("audio/ogg")), "ogg");
|
||||
assert_eq!(extension_of(Some("weird.name with spaces"), None), "bin");
|
||||
assert_eq!(kind_of_mime("image/webp"), "image");
|
||||
assert_eq!(kind_of_mime("image/gif"), "video");
|
||||
assert_eq!(kind_of_mime("application/pdf"), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_to_draw_is_the_smallest_that_reaches_the_target_else_the_largest() {
|
||||
let sizes = vec![("s", 90), ("y", 1280), ("m", 320), ("x", 800)];
|
||||
assert_eq!(choose(&sizes, 320), Some(&"m"));
|
||||
assert_eq!(choose(&sizes, 1280), Some(&"y"));
|
||||
assert_eq!(choose(&sizes, 5000), Some(&"y"));
|
||||
assert_eq!(choose(&sizes, 100), Some(&"m"));
|
||||
assert_eq!(choose::<&str>(&[], 320), None);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//! Who is in a group: the list beside the conversation.
|
||||
//!
|
||||
//! Telegram will list the members of a group or a supergroup (a channel's subscribers are for its administrators
|
||||
//! only), two hundred at a time. The first page is read when somebody opens the conversation, with the group's own
|
||||
//! count, and the owner and administrators carry a mark in front of their name.
|
||||
|
||||
use super::media;
|
||||
use super::messages;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::peer::Role;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How many members are read: Telegram's own page.
|
||||
const LIMIT: usize = 200;
|
||||
|
||||
/// What stands in front of an owner's or an administrator's name.
|
||||
const OWNER: &str = "👑";
|
||||
const ADMIN: &str = "@";
|
||||
|
||||
/// Reads a group's members and says so, as the list and as how many there are in all.
|
||||
pub async fn refresh(state: &AppState, buffer: &Buffer) -> Result<usize, String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
// A person's conversation, a channel somebody only follows, or the account's own: nobody to list.
|
||||
if buffer.kind == "dm" || buffer.category.as_deref() == Some(super::peers::CHANNELS) {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut iter = handle.client.iter_participants(peer);
|
||||
let total = iter.total().await.unwrap_or(0);
|
||||
let mut members: Vec<Value> = Vec::new();
|
||||
while members.len() < LIMIT {
|
||||
let Some(found) = iter.next().await.map_err(|e| explain(&e))? else { break };
|
||||
let (prefix, banned) = match &found.role {
|
||||
Role::Creator(_) => (OWNER, false),
|
||||
Role::Admin(_) => (ADMIN, false),
|
||||
Role::Banned(_) | Role::Left(_) => ("", true),
|
||||
_ => ("", false),
|
||||
};
|
||||
if banned {
|
||||
continue;
|
||||
}
|
||||
let peer = grammers_client::peer::Peer::User(found.user.clone());
|
||||
handle.names.lock().unwrap_or_else(|e| e.into_inner()).insert(found.user.id().bare_id_unchecked(), messages::name_of(&peer));
|
||||
members.push(json!({
|
||||
"nick": messages::name_of(&peer),
|
||||
"userId": found.user.id().bare_id_unchecked().to_string(),
|
||||
"prefix": prefix,
|
||||
"bot": found.user.is_bot(),
|
||||
"avatarUrl": media::avatar_url(handle.own.user_id, &peer),
|
||||
}));
|
||||
}
|
||||
let count = members.len();
|
||||
let list = Value::Array(members);
|
||||
state.runtime.set_presence(&buffer.id, list.clone());
|
||||
state.runtime.set_presence_total(&buffer.id, total.max(count));
|
||||
state.events.emit("presenceChange", json!({ "bufferId": buffer.id, "members": list, "total": total.max(count) }));
|
||||
Ok(count)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//! Joining and leaving: a public chat by its @name or t.me link, a private one by its invite link, and leaving what
|
||||
//! the account is in.
|
||||
//!
|
||||
//! A chat that was joined arrives as a dialog, so the list is read again afterwards and the window opens the new
|
||||
//! conversation as it does for any service.
|
||||
|
||||
use super::dialogs;
|
||||
use super::peers;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::peer::Peer;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerKind;
|
||||
|
||||
/// What a person typed to name somewhere to join.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Target {
|
||||
/// A public name: a person, a group or a channel.
|
||||
Username(String),
|
||||
/// The secret part of a private invite link.
|
||||
Invite(String),
|
||||
}
|
||||
|
||||
/// Reads `@name`, `name`, `t.me/name`, `https://t.me/+hash`, `t.me/joinchat/hash` and `tg://join?invite=hash`.
|
||||
pub fn parse_target(input: &str) -> Option<Target> {
|
||||
let text = input.trim();
|
||||
if let Some(rest) = text.strip_prefix("tg://join?invite=") {
|
||||
return clean_invite(rest).map(Target::Invite);
|
||||
}
|
||||
let rest = text.strip_prefix("https://").or_else(|| text.strip_prefix("http://")).unwrap_or(text);
|
||||
let path = ["t.me/", "telegram.me/", "telegram.dog/"].iter().find_map(|host| rest.strip_prefix(host));
|
||||
let Some(path) = path else {
|
||||
let name = text.strip_prefix('@').unwrap_or(text);
|
||||
return valid_username(name).then(|| Target::Username(name.to_string()));
|
||||
};
|
||||
let path = path.split(['?', '#']).next().unwrap_or("");
|
||||
let mut parts = path.split('/').filter(|p| !p.is_empty());
|
||||
let first = parts.next()?;
|
||||
if first == "joinchat" {
|
||||
return clean_invite(parts.next()?).map(Target::Invite);
|
||||
}
|
||||
if let Some(hash) = first.strip_prefix('+') {
|
||||
return clean_invite(hash).map(Target::Invite);
|
||||
}
|
||||
valid_username(first).then(|| Target::Username(first.to_string()))
|
||||
}
|
||||
|
||||
fn clean_invite(hash: &str) -> Option<String> {
|
||||
let hash = hash.trim();
|
||||
(!hash.is_empty() && hash.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')).then(|| hash.to_string())
|
||||
}
|
||||
|
||||
/// Telegram's rule: 5 to 32 letters, digits and underscores, starting with a letter.
|
||||
fn valid_username(name: &str) -> bool {
|
||||
(5..=32).contains(&name.len()) && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// Joins what was named, and makes its conversation.
|
||||
pub async fn join(state: &AppState, account_id: &str, input: &str) -> Result<(), String> {
|
||||
let target = parse_target(input).ok_or_else(|| format!("\"{}\" is not a Telegram @name or t.me link", input.trim()))?;
|
||||
let handle = state.runtime.telegram_handle(account_id).ok_or_else(|| "that Telegram account is not connected".to_string())?;
|
||||
match target {
|
||||
Target::Invite(hash) => {
|
||||
handle.client.invoke(&tl::functions::messages::ImportChatInvite { hash }).await.map_err(|e| match &e {
|
||||
grammers_client::InvocationError::Rpc(r) if r.name == "INVITE_HASH_EXPIRED" || r.name == "INVITE_HASH_INVALID" => "That invite link is no longer valid".to_string(),
|
||||
grammers_client::InvocationError::Rpc(r) if r.name == "INVITE_REQUEST_SENT" => "Your request to join was sent: an administrator has to accept it".to_string(),
|
||||
_ => explain(&e),
|
||||
})?;
|
||||
}
|
||||
Target::Username(name) => {
|
||||
let peer = handle.client.resolve_username(&name).await.map_err(|e| explain(&e))?.ok_or_else(|| format!("Nobody on Telegram is called @{name}"))?;
|
||||
match &peer {
|
||||
// A person: the conversation with them is all there is to open.
|
||||
Peer::User(_) => {
|
||||
let buffer = peers::buffer_for(state, account_id, &peer);
|
||||
if let Some(avatar) = super::media::avatar_url(handle.own.user_id, &peer) {
|
||||
state.runtime.set_buffer_avatar(state, &buffer.id, &avatar);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
let peer_ref = peer.to_ref().await.ok().flatten().ok_or_else(|| "Telegram did not say how to reach that chat".to_string())?;
|
||||
handle.client.join_chat(peer_ref).await.map_err(|e| match &e {
|
||||
grammers_client::InvocationError::Rpc(r) if r.name == "INVITE_REQUEST_SENT" => "Your request to join was sent: an administrator has to accept it".to_string(),
|
||||
_ => explain(&e),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dialogs::sync(state, account_id, &handle).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaves a conversation: a channel or supergroup is left, a small group is left, a private chat is cleared.
|
||||
pub async fn leave(state: &AppState, buffer: &Buffer) -> Result<(), String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
match peer.id.kind() {
|
||||
PeerKind::Channel => {
|
||||
handle.client.invoke(&tl::functions::channels::LeaveChannel { channel: peer.into() }).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
PeerKind::Chat => {
|
||||
handle.client.invoke(&tl::functions::messages::DeleteChatUser { revoke_history: false, chat_id: peer.into(), user_id: tl::enums::InputUser::UserSelf }).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
_ => {
|
||||
// History is deleted from this account's side only; the other person keeps theirs.
|
||||
handle.client.invoke(&tl::functions::messages::DeleteHistory { just_clear: false, revoke: false, peer: peer.into(), max_id: 0, min_date: None, max_date: None }).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_and_links_are_read() {
|
||||
let user = |n: &str| Some(Target::Username(n.to_string()));
|
||||
let invite = |h: &str| Some(Target::Invite(h.to_string()));
|
||||
assert_eq!(parse_target("@abcd"), None, "too short for Telegram, so not a name");
|
||||
assert_eq!(parse_target("@disclosetv"), user("disclosetv"));
|
||||
assert_eq!(parse_target("disclosetv"), user("disclosetv"));
|
||||
assert_eq!(parse_target("https://t.me/disclosetv"), user("disclosetv"));
|
||||
assert_eq!(parse_target("t.me/disclosetv/1234?single"), user("disclosetv"));
|
||||
assert_eq!(parse_target("https://t.me/+AbC_d-123"), invite("AbC_d-123"));
|
||||
assert_eq!(parse_target("https://t.me/joinchat/AbC_d-123"), invite("AbC_d-123"));
|
||||
assert_eq!(parse_target("tg://join?invite=AbC_d-123"), invite("AbC_d-123"));
|
||||
assert_eq!(parse_target("two words"), None);
|
||||
assert_eq!(parse_target("1startsdigit"), None);
|
||||
assert_eq!(parse_target(""), None);
|
||||
}
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
//! A Telegram message as moho's: who said it, what it says, what it answers, what came with it.
|
||||
//!
|
||||
//! Telegram marks up a message with entities - a kind, an offset and a length, counted in UTF-16 code units - which
|
||||
//! become the message's `html` (bold, italic, underline, strike, code, a quote, a link, a spoiler) beside its plain
|
||||
//! `body`. Media is not drawn until Round 4; a message that carries some says what it is in words, after its caption.
|
||||
|
||||
use super::media;
|
||||
use super::peers;
|
||||
use crate::model::ReplyPreview;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::message::Message;
|
||||
use grammers_client::peer::Peer;
|
||||
use grammers_client::tl;
|
||||
|
||||
/// What the connection knows about the account itself: its name here, which a message it sent is attributed to.
|
||||
#[derive(Clone)]
|
||||
pub struct Own {
|
||||
pub name: String,
|
||||
pub user_id: i64,
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// A span to mark: where it starts and ends in UTF-16 units, and the tags that open and close it.
|
||||
struct Span {
|
||||
from: usize,
|
||||
to: usize,
|
||||
open: String,
|
||||
close: &'static str,
|
||||
}
|
||||
|
||||
fn span_of(entity: &tl::enums::MessageEntity, text16: &[u16]) -> Option<Span> {
|
||||
use tl::enums::MessageEntity as E;
|
||||
let (offset, length, open, close): (i32, i32, String, &'static str) = match entity {
|
||||
E::Bold(e) => (e.offset, e.length, "<b>".into(), "</b>"),
|
||||
E::Italic(e) => (e.offset, e.length, "<i>".into(), "</i>"),
|
||||
E::Underline(e) => (e.offset, e.length, "<u>".into(), "</u>"),
|
||||
E::Strike(e) => (e.offset, e.length, "<s>".into(), "</s>"),
|
||||
E::Code(e) => (e.offset, e.length, "<code>".into(), "</code>"),
|
||||
E::Pre(e) => (e.offset, e.length, "<pre>".into(), "</pre>"),
|
||||
E::Blockquote(e) => (e.offset, e.length, "<blockquote>".into(), "</blockquote>"),
|
||||
E::Spoiler(e) => (e.offset, e.length, "<span class=\"spoiler\">".into(), "</span>"),
|
||||
E::TextUrl(e) => (e.offset, e.length, format!("<a href=\"{}\">", escape(&e.url)), "</a>"),
|
||||
E::Url(e) => {
|
||||
// The link is its own text.
|
||||
let (from, to) = (e.offset.max(0) as usize, ((e.offset + e.length).max(0) as usize).min(text16.len()));
|
||||
let target = String::from_utf16_lossy(text16.get(from..to)?);
|
||||
let target = if target.contains("://") { target } else { format!("https://{target}") };
|
||||
(e.offset, e.length, format!("<a href=\"{}\">", escape(&target)), "</a>")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let from = offset.max(0) as usize;
|
||||
let to = ((offset + length).max(0) as usize).min(text16.len());
|
||||
(from < to).then_some(Span { from, to, open, close })
|
||||
}
|
||||
|
||||
/// The message's text with its entities as html, or `None` when it has none worth drawing.
|
||||
pub fn entities_html(text: &str, entities: &[tl::enums::MessageEntity]) -> Option<String> {
|
||||
let text16: Vec<u16> = text.encode_utf16().collect();
|
||||
let mut spans: Vec<Span> = entities.iter().filter_map(|e| span_of(e, &text16)).collect();
|
||||
if spans.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Outer spans open first: by where they start, the longer before the shorter.
|
||||
spans.sort_by(|a, b| a.from.cmp(&b.from).then(b.to.cmp(&a.to)));
|
||||
let mut out = String::new();
|
||||
let mut open: Vec<&Span> = Vec::new();
|
||||
let mut next = 0;
|
||||
let mut position = 0;
|
||||
let mut run = String::new();
|
||||
let flush = |run: &mut String, out: &mut String| {
|
||||
if !run.is_empty() {
|
||||
out.push_str(&escape(run).replace('\n', "<br>"));
|
||||
run.clear();
|
||||
}
|
||||
};
|
||||
let mut units = text16.iter().copied().peekable();
|
||||
while position <= text16.len() {
|
||||
// What ends here closes first, innermost first.
|
||||
while let Some(last) = open.last() {
|
||||
if last.to <= position {
|
||||
flush(&mut run, &mut out);
|
||||
out.push_str(last.close);
|
||||
open.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while next < spans.len() && spans[next].from <= position {
|
||||
flush(&mut run, &mut out);
|
||||
out.push_str(&spans[next].open);
|
||||
open.push(&spans[next]);
|
||||
next += 1;
|
||||
}
|
||||
let Some(unit) = units.next() else { break };
|
||||
// A character outside the basic plane is two units: take both together.
|
||||
let mut pair = vec![unit];
|
||||
if (0xD800..0xDC00).contains(&unit) {
|
||||
if let Some(low) = units.next() {
|
||||
pair.push(low);
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
run.push_str(&String::from_utf16_lossy(&pair));
|
||||
position += 1;
|
||||
}
|
||||
flush(&mut run, &mut out);
|
||||
while let Some(last) = open.pop() {
|
||||
out.push_str(last.close);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// What a message with media says it carries, in words.
|
||||
pub fn media_label(media: &tl::enums::MessageMedia) -> Option<String> {
|
||||
use tl::enums::MessageMedia as M;
|
||||
Some(match media {
|
||||
M::Empty | M::Unsupported => return None,
|
||||
// A link's card is the link already in the text.
|
||||
M::WebPage(_) => return None,
|
||||
M::Photo(_) => "[Photo]".to_string(),
|
||||
M::Geo(_) | M::GeoLive(_) => "[Location]".to_string(),
|
||||
M::Venue(v) => format!("[Place: {}]", v.title),
|
||||
M::Contact(c) => format!("[Contact: {} {}]", c.first_name, c.last_name).trim_end().replace(" ]", "]"),
|
||||
M::Poll(p) => format!("[Poll: {}]", poll_question(&p.poll)),
|
||||
M::Dice(d) => format!("[{} {}]", d.emoticon, d.value),
|
||||
M::Document(d) => match &d.document {
|
||||
Some(tl::enums::Document::Document(doc)) => document_label(doc),
|
||||
_ => "[File]".to_string(),
|
||||
},
|
||||
_ => "[Attachment]".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn poll_question(poll: &tl::enums::Poll) -> String {
|
||||
match poll {
|
||||
tl::enums::Poll::Poll(p) => match &p.question {
|
||||
tl::enums::TextWithEntities::Entities(t) => t.text.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn document_label(doc: &tl::types::Document) -> String {
|
||||
use tl::enums::DocumentAttribute as A;
|
||||
let name = doc.attributes.iter().find_map(|a| if let A::Filename(f) = a { Some(f.file_name.clone()) } else { None });
|
||||
for attribute in &doc.attributes {
|
||||
match attribute {
|
||||
A::Sticker(s) => return format!("[Sticker {}]", s.alt).replace("[Sticker ]", "[Sticker]"),
|
||||
A::Audio(a) if a.voice => return "[Voice message]".to_string(),
|
||||
A::Audio(a) => return format!("[Audio: {}]", a.title.clone().or(name.clone()).unwrap_or_else(|| "track".to_string())),
|
||||
A::Video(v) if v.round_message => return "[Video message]".to_string(),
|
||||
A::Video(_) => return format!("[Video{}]", name.map(|n| format!(": {n}")).unwrap_or_default()),
|
||||
A::Animated => return "[GIF]".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
format!("[File{}]", name.map(|n| format!(": {n}")).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// What a service message says (somebody joined, the title changed), or `None` for those with nothing to say.
|
||||
pub fn service_text(who: &str, action: &tl::enums::MessageAction) -> Option<String> {
|
||||
use tl::enums::MessageAction as A;
|
||||
Some(match action {
|
||||
A::ChatCreate(a) => format!("{who} created the group \"{}\"", a.title),
|
||||
A::ChatEditTitle(a) => format!("{who} changed the title to \"{}\"", a.title),
|
||||
A::ChatEditPhoto(_) => format!("{who} changed the group photo"),
|
||||
A::ChatDeletePhoto => format!("{who} removed the group photo"),
|
||||
A::ChatAddUser(a) => format!("{who} added {} {}", a.users.len(), if a.users.len() == 1 { "person" } else { "people" }),
|
||||
A::ChatDeleteUser(_) => format!("{who} removed somebody from the group"),
|
||||
A::ChatJoinedByLink(_) => format!("{who} joined by invite link"),
|
||||
A::ChatJoinedByRequest => format!("{who} joined after being approved"),
|
||||
A::ChannelCreate(a) => format!("The channel \"{}\" was created", a.title),
|
||||
A::PinMessage => format!("{who} pinned a message"),
|
||||
A::HistoryClear => "The history was cleared".to_string(),
|
||||
A::ScreenshotTaken => format!("{who} took a screenshot"),
|
||||
A::ContactSignUp => format!("{who} joined Telegram"),
|
||||
A::SetMessagesTtl(a) => if a.period == 0 { "Messages no longer disappear".to_string() } else { format!("Messages now disappear after {} seconds", a.period) },
|
||||
A::PhoneCall(a) => format!("{who} {} call", if a.video { "had a video" } else { "had a voice" }),
|
||||
A::TopicCreate(a) => format!("{who} created the topic \"{}\"", a.title),
|
||||
A::Empty => return None,
|
||||
_ => format!("{who} did something Telegram describes in a way moho does not show yet"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A person's name, for a line that says who did something.
|
||||
pub fn name_of(peer: &Peer) -> String {
|
||||
peers::title_of(peer)
|
||||
}
|
||||
|
||||
/// Everything about a message that is words: the caption or text and what came with it. Where it was forwarded from is
|
||||
/// said above it (see `forward_preview`), not in it.
|
||||
pub fn body_of(message: &Message) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let text = message.text();
|
||||
if !text.is_empty() {
|
||||
parts.push(text.to_string());
|
||||
}
|
||||
if let tl::enums::Message::Message(m) = &message.raw {
|
||||
if let Some(label) = m.media.as_ref().and_then(media_label) {
|
||||
parts.push(label);
|
||||
}
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Records a message in its conversation, as it arrived live or as history. `false` when it was already there.
|
||||
pub fn record(state: &AppState, account_id: &str, own: &Own, message: &Message) -> bool {
|
||||
record_named(state, account_id, own, message, None)
|
||||
}
|
||||
|
||||
/// The same, remembering who the sender is for `names` when it is given.
|
||||
pub fn record_named(state: &AppState, account_id: &str, own: &Own, message: &Message, names: Option<&std::sync::Mutex<std::collections::HashMap<i64, String>>>) -> bool {
|
||||
let Some(peer) = message.peer() else { return false };
|
||||
record_in(state, account_id, own, message, peer, message.sender(), names)
|
||||
}
|
||||
|
||||
/// The same for a message whose conversation the caller found. A message a person sent in a private chat arrives as
|
||||
/// a short update that names no one, so the library cannot say who it is from or where it is: the caller asks
|
||||
/// Telegram, and the other person in a private chat is the sender of everything not sent by the account.
|
||||
pub fn record_in(state: &AppState, account_id: &str, own: &Own, message: &Message, peer: &Peer, sender: Option<&Peer>, names: Option<&std::sync::Mutex<std::collections::HashMap<i64, String>>>) -> bool {
|
||||
let buffer = peers::buffer_for(state, account_id, peer);
|
||||
let outgoing = message.outgoing();
|
||||
let sender = sender.or_else(|| (!outgoing && matches!(peer, Peer::User(_))).then_some(peer));
|
||||
if let (Some(names), Some(Peer::User(user))) = (names, sender) {
|
||||
names.lock().unwrap_or_else(|e| e.into_inner()).insert(user.id().bare_id_unchecked(), name_of(&Peer::User(user.clone())));
|
||||
}
|
||||
let from = if outgoing {
|
||||
own.name.clone()
|
||||
} else {
|
||||
sender.map(name_of).unwrap_or_else(|| peers::title_of(peer))
|
||||
};
|
||||
let sender_id = if outgoing { Some(own.user_id.to_string()) } else { message.sender_id().map(peers::remote_id) };
|
||||
let ts = message.date().as_second();
|
||||
let id = message.id().to_string();
|
||||
|
||||
if let Some(action) = message.action() {
|
||||
let Some(text) = service_text(&from, action) else { return false };
|
||||
return state.runtime.record_message_at(state, account_id, &buffer.name, &buffer.kind, "Telegram", &text, false, "system", None, Some(id), false, None, Vec::new(), Vec::new(), None, Some(ts), None, None);
|
||||
}
|
||||
|
||||
let topic_only = matches!(&message.raw, tl::enums::Message::Message(m) if m.reply_to.as_ref().is_some_and(is_topic_root));
|
||||
let answered = if topic_only { None } else { reply_preview(state, account_id, &buffer, message) };
|
||||
let forward = message.forward_header().map(|h| forward_preview(state, account_id, &h));
|
||||
// One slot above a message: an answer wins, and says it was forwarded in its text.
|
||||
let (reply, forward_line) = match (answered, forward) {
|
||||
(Some(answer), Some(forwarded)) => (Some(answer), Some(format!("↪ Forwarded from {}", forwarded.from))),
|
||||
(Some(answer), None) => (Some(answer), None),
|
||||
(None, forwarded) => (forwarded, None),
|
||||
};
|
||||
let ask_for_reply = reply.as_ref().filter(|r| r.body.is_empty() && !r.forwarded).map(|r| (r.id.clone(), r.chat.clone()));
|
||||
let has_forward_line = forward_line.is_some();
|
||||
let body = match forward_line {
|
||||
Some(line) => format!("{line}\n{}", body_of(message)),
|
||||
None => body_of(message),
|
||||
};
|
||||
let html = message.fmt_entities().and_then(|e| entities_html(message.text(), e)).filter(|h| !h.is_empty());
|
||||
// The first line of a forward is words of our own, so the html (made from the text alone) would lose it: a
|
||||
// forwarded message is shown as plain text.
|
||||
let html = if has_forward_line { None } else { html };
|
||||
let attachments = buffer.remote_id.as_deref().map(|remote| media::attachments_of(own.user_id, remote, message)).unwrap_or_default();
|
||||
let avatar = if outgoing { None } else { sender.and_then(|p| media::avatar_url(own.user_id, p)) };
|
||||
if let Some(remote) = buffer.remote_id.as_deref() {
|
||||
media::prefetch_preview(state, account_id, own.user_id, remote, message);
|
||||
}
|
||||
let again = attachments.clone();
|
||||
let reply_target = ask_for_reply.map(|(reply_id, chat)| {
|
||||
// Where the answered message is: the other conversation if it is one, else this.
|
||||
let remote = chat.and_then(|c| state.runtime.get_buffer(&c)).and_then(|b| b.remote_id).or_else(|| buffer.remote_id.clone());
|
||||
(remote, reply_id)
|
||||
});
|
||||
let cards = buffer.remote_id.as_deref().map(|remote| media::embeds_of(own.user_id, remote, message)).unwrap_or_default();
|
||||
let cards_again = cards.clone();
|
||||
let recorded = state.runtime.record_message_at(state, account_id, &buffer.name, &buffer.kind, &from, &body, false, "chat", reply, Some(id.clone()), message.mentioned(), avatar, cards, attachments, sender_id, Some(ts), html, None);
|
||||
if !recorded && !cards_again.is_empty() {
|
||||
if let Ok(Some(stored)) = state.store.get_message(&buffer.id, &id) {
|
||||
if stored.embeds != cards_again && state.store.set_message_embeds(&buffer.id, &id, &cards_again).unwrap_or(false) {
|
||||
state.events.emit("messageUpdated", serde_json::json!({ "bufferId": buffer.id, "id": id, "body": stored.body, "edited": false, "embeds": cards_again, "attachments": stored.attachments }));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read again, a message already kept without its picture or file gets it: earlier builds kept the text alone.
|
||||
if !recorded && !again.is_empty() {
|
||||
if let Ok(Some(stored)) = state.store.get_message(&buffer.id, &id) {
|
||||
if stored.attachments.is_empty() && state.store.update_message_attachments(&buffer.id, &id, &again).unwrap_or(false) {
|
||||
state.events.emit("messageUpdated", serde_json::json!({ "bufferId": buffer.id, "id": id, "body": stored.body, "edited": false, "embeds": stored.embeds, "attachments": again }));
|
||||
}
|
||||
}
|
||||
}
|
||||
// What a reply answered, when it was not in hand: asked of Telegram, and the row is filled in when it comes.
|
||||
if let Some((Some(remote), reply_id)) = reply_target {
|
||||
fill_reply_later(state, account_id, &buffer.id, &id, &remote, &reply_id);
|
||||
}
|
||||
// A poll that is still open is the conversation's card.
|
||||
if let tl::enums::Message::Message(raw) = &message.raw {
|
||||
if let Some(tl::enums::MessageMedia::Poll(p)) = &raw.media {
|
||||
let (tl::enums::Poll::Poll(poll), tl::enums::PollResults::Results(results)) = (&p.poll, &p.results);
|
||||
super::polls::announce(state, &buffer.id, message.id(), poll, results);
|
||||
}
|
||||
}
|
||||
// What people have reacted with, which a message arrives carrying.
|
||||
if let tl::enums::Message::Message(m) = &message.raw {
|
||||
// How many have answered it, or commented under it if it is a channel's post.
|
||||
if let Some(tl::enums::MessageReplies::Replies(r)) = &m.replies {
|
||||
if r.replies > 0 {
|
||||
state.events.emit("threadCount", serde_json::json!({ "bufferId": buffer.id, "id": id, "count": r.replies }));
|
||||
}
|
||||
}
|
||||
if let Some(reactions) = &m.reactions {
|
||||
let list = super::reactions::from_tl(reactions);
|
||||
if !list.is_empty() {
|
||||
super::reactions::apply(state, &buffer.id, &id, &list);
|
||||
}
|
||||
}
|
||||
}
|
||||
recorded
|
||||
}
|
||||
|
||||
/// Where a forwarded message came from, as the line above it: the name of the source and, when it was a post in a
|
||||
/// channel this account has open, where to find it.
|
||||
fn forward_preview(state: &AppState, account_id: &str, header: &tl::enums::MessageFwdHeader) -> ReplyPreview {
|
||||
let tl::enums::MessageFwdHeader::Header(h) = header;
|
||||
let source = h.from_id.clone().map(|p| peers::remote_id(grammers_session::types::PeerId::from(p)));
|
||||
let origin = source.as_ref().and_then(|remote| state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(remote.as_str())));
|
||||
let from = origin.as_ref().map(|b| b.name.clone()).or_else(|| h.from_name.clone()).or_else(|| h.post_author.clone()).unwrap_or_else(|| "somebody".to_string());
|
||||
// Only a post in a channel can be gone to: a person's forward has no message of theirs to point at.
|
||||
let (chat, id) = match (&origin, h.channel_post) {
|
||||
(Some(b), Some(post)) if b.kind == "channel" => (Some(b.id.clone()), post.to_string()),
|
||||
_ => (None, String::new()),
|
||||
};
|
||||
ReplyPreview { id, from, body: String::new(), thread: false, forwarded: true, thread_root: None, chat }
|
||||
}
|
||||
|
||||
/// What a message answers, as the quote above it: the words quoted (or the message answered), who said them, and - when
|
||||
/// it was said in another conversation - where, so the reader can go there.
|
||||
fn reply_preview(state: &AppState, account_id: &str, buffer: &crate::model::Buffer, message: &Message) -> Option<ReplyPreview> {
|
||||
let reply_id = message.reply_to_message_id()?;
|
||||
let header = match &message.raw {
|
||||
tl::enums::Message::Message(m) => match &m.reply_to {
|
||||
Some(tl::enums::MessageReplyHeader::Header(h)) => Some(h.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let elsewhere = header
|
||||
.as_ref()
|
||||
.and_then(|h| h.reply_to_peer_id.clone())
|
||||
.map(|p| peers::remote_id(grammers_session::types::PeerId::from(p)))
|
||||
.filter(|remote| buffer.remote_id.as_deref() != Some(remote.as_str()));
|
||||
let other = elsewhere.as_ref().and_then(|remote| state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(remote.as_str())));
|
||||
let source = other.as_ref().map(|b| b.id.clone()).unwrap_or_else(|| buffer.id.clone());
|
||||
// A message in a conversation this account has not got open is not one to look for here.
|
||||
let stored = if elsewhere.is_some() && other.is_none() { None } else { state.store.get_message(&source, &reply_id.to_string()).ok().flatten() };
|
||||
let quote = header.as_ref().and_then(|h| h.quote_text.clone()).filter(|t| !t.trim().is_empty());
|
||||
let named = header.as_ref().and_then(|h| h.reply_from.as_ref()).and_then(|f| {
|
||||
let tl::enums::MessageFwdHeader::Header(f) = f;
|
||||
f.from_name.clone().or_else(|| f.post_author.clone())
|
||||
});
|
||||
let shown_media = header.as_ref().and_then(|h| h.reply_media.as_ref()).and_then(media_label);
|
||||
let from = other.as_ref().map(|b| b.name.clone()).or(named).or_else(|| stored.as_ref().map(|m| m.from.clone())).unwrap_or_default();
|
||||
let body = quote.or_else(|| stored.map(|m| m.body)).or(shown_media).unwrap_or_default();
|
||||
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false, thread_root: None, chat: other.map(|b| b.id) })
|
||||
}
|
||||
|
||||
/// Asks Telegram what a reply answered and fills the quote in.
|
||||
fn fill_reply_later(state: &AppState, account_id: &str, buffer_id: &str, msg_id: &str, remote: &str, reply_id: &str) {
|
||||
let (Some(handle), Some(id), Ok(reply)) = (state.runtime.telegram_handle(account_id), peers::peer_id_of(remote), reply_id.parse::<i32>()) else { return };
|
||||
let (state, buffer_id, msg_id) = (state.clone(), buffer_id.to_string(), msg_id.to_string());
|
||||
tokio::spawn(async move {
|
||||
use grammers_session::Session;
|
||||
let Some(peer) = handle.session.peer_ref(id).await.ok().flatten() else { return };
|
||||
let Ok(found) = handle.client.get_messages_by_id(peer, &[reply]).await else { return };
|
||||
let Some(Some(original)) = found.into_iter().next() else { return };
|
||||
let body = body_of(&original);
|
||||
let from = original.sender().map(name_of).unwrap_or_else(|| original.peer().map(peers::title_of).unwrap_or_default());
|
||||
if !body.is_empty() {
|
||||
state.runtime.fill_reply_preview(&state, &buffer_id, &msg_id, &from, &body);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// In a group with topics every message "replies" to its topic's first message; that is where it is, not who it
|
||||
/// answers. A real reply names a message and the topic it is in as well.
|
||||
fn is_topic_root(header: &tl::enums::MessageReplyHeader) -> bool {
|
||||
matches!(header, tl::enums::MessageReplyHeader::Header(h) if h.forum_topic && h.reply_to_top_id.is_none())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tl::types as t;
|
||||
|
||||
fn bold(offset: i32, length: i32) -> tl::enums::MessageEntity {
|
||||
tl::enums::MessageEntity::Bold(t::MessageEntityBold { offset, length })
|
||||
}
|
||||
fn italic(offset: i32, length: i32) -> tl::enums::MessageEntity {
|
||||
tl::enums::MessageEntity::Italic(t::MessageEntityItalic { offset, length })
|
||||
}
|
||||
|
||||
fn header(forum_topic: bool, msg: i32, top: Option<i32>) -> tl::enums::MessageReplyHeader {
|
||||
tl::enums::MessageReplyHeader::Header(t::MessageReplyHeader {
|
||||
reply_to_scheduled: false,
|
||||
forum_topic,
|
||||
quote: false,
|
||||
reply_to_ephemeral: false,
|
||||
reply_to_msg_id: Some(msg),
|
||||
reply_to_peer_id: None,
|
||||
reply_from: None,
|
||||
reply_media: None,
|
||||
reply_to_top_id: top,
|
||||
quote_text: None,
|
||||
quote_entities: None,
|
||||
quote_offset: None,
|
||||
todo_item_id: None,
|
||||
poll_option: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_in_a_topic_is_not_a_reply_to_the_topic() {
|
||||
assert!(is_topic_root(&header(true, 10, None)));
|
||||
assert!(!is_topic_root(&header(true, 55, Some(10))), "answering a message inside the topic");
|
||||
assert!(!is_topic_root(&header(false, 10, None)), "an ordinary reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_become_html_and_nest() {
|
||||
assert_eq!(entities_html("hello world", &[bold(0, 5)]).unwrap(), "<b>hello</b> world");
|
||||
assert_eq!(entities_html("a bc d", &[bold(2, 2), italic(2, 1)]).unwrap(), "a <b><i>b</i>c</b> d");
|
||||
assert_eq!(entities_html("plain", &[]), None);
|
||||
// The text is escaped, and a line break is one.
|
||||
assert_eq!(entities_html("a < b\nc", &[bold(0, 1)]).unwrap(), "<b>a</b> < b<br>c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_are_in_utf16_units_so_an_emoji_before_the_text_moves_it_by_two() {
|
||||
// "😀 hi": the emoji is two units, the space one, so "hi" starts at 3.
|
||||
assert_eq!(entities_html("😀 hi", &[bold(3, 2)]).unwrap(), "😀 <b>hi</b>");
|
||||
// A span that runs past the end is cut at it rather than panicking.
|
||||
assert_eq!(entities_html("ab", &[bold(1, 50)]).unwrap(), "a<b>b</b>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_is_its_own_text_or_its_url_and_a_bare_domain_gets_a_scheme() {
|
||||
let url = tl::enums::MessageEntity::Url(t::MessageEntityUrl { offset: 4, length: 11 });
|
||||
assert_eq!(entities_html("see example.com now", &[url]).unwrap(), "see <a href=\"https://example.com\">example.com</a> now");
|
||||
let text_url = tl::enums::MessageEntity::TextUrl(t::MessageEntityTextUrl { offset: 0, length: 4, url: "https://a.example/?q=1&r=2".into() });
|
||||
assert_eq!(entities_html("here", &[text_url]).unwrap(), "<a href=\"https://a.example/?q=1&r=2\">here</a>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_messages_say_who_did_what() {
|
||||
let title = tl::enums::MessageAction::ChatEditTitle(t::MessageActionChatEditTitle { title: "Lunch".into() });
|
||||
assert_eq!(service_text("Ann", &title).as_deref(), Some("Ann changed the title to \"Lunch\""));
|
||||
assert_eq!(service_text("Ann", &tl::enums::MessageAction::PinMessage).as_deref(), Some("Ann pinned a message"));
|
||||
assert_eq!(service_text("Ann", &tl::enums::MessageAction::Empty), None);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! Telegram, over MTProto, in pure Rust (the `grammers` crates).
|
||||
//!
|
||||
//! # How this folder is arranged
|
||||
//!
|
||||
//! Built in rounds, as Twitch was (see Gitea issue #26 for the checklist and what has been verified):
|
||||
//!
|
||||
//! - this file - the identity moho gives Telegram, and what every other file shares
|
||||
//! - `session` - what the library remembers between runs, in a private file of its own per account
|
||||
//! - `link` - one connection to Telegram: the sender pool, the client and the stream of updates
|
||||
//! - `login` - adding an account: phone, code, cloud password
|
||||
//! - `connect` - an account's connection, kept up and reconnected
|
||||
//! - `dialogs` - the account's conversations, read when it connects
|
||||
//! - `history` - a conversation's history, read when it is opened and scrolled back
|
||||
//! - `markup` - what the message box holds, as plain text and entities
|
||||
//! - `updates` - what Telegram sends that is not a message: typing, reads on other apps
|
||||
//! - `send` - saying things: send, answer, edit, delete, typing, read
|
||||
//! - `media` - pictures, video, files and voice: what a message carries, fetched when it is looked at
|
||||
//! - `reactions` - the emoji on a message, and the account's own
|
||||
//! - `pins` - a conversation's pinned messages
|
||||
//! - `members` - who is in a group
|
||||
//! - `profile` - the card for a person
|
||||
//! - `search` - searching on Telegram's side
|
||||
//! - `polls` - a poll as the conversation's card, voted in from it
|
||||
//! - `threads` - replies in a thread, and a channel post's comments
|
||||
//! - `membership` - joining by @name or link, leaving
|
||||
//! - `admin` - what an administrator may do to a member
|
||||
//! - `notify` - muting a chat on the account
|
||||
//! - `instant` - an Instant View article as a page of its own
|
||||
//! - `peers` - a conversation as a buffer: its name, its kind, its heading in the list
|
||||
//! - `messages` - a message as moho's, with its entities as html
|
||||
//!
|
||||
//! # The api_id
|
||||
//!
|
||||
//! Telegram gives one `api_id` and `api_hash` to a phone number's owner, and a client sends them with every
|
||||
//! connection. Moho does not embed one: each account carries its own, typed into the sign-in form, and they are
|
||||
//! kept with the session like a password. Nothing here logs them.
|
||||
//!
|
||||
//! # What moho tells Telegram about itself
|
||||
//!
|
||||
//! Telegram lists every session of an account (Settings, Devices) with the app and device each one reported, and
|
||||
//! treats a stream of unfamiliar sessions as a risk. So the identity is set once, is the same on every connection,
|
||||
//! and says what this is: the device is "moho" on this operating system, the version is moho's own. A session is
|
||||
//! saved and reused; nothing here signs in afresh to get over a hiccup.
|
||||
|
||||
pub mod admin;
|
||||
pub mod connect;
|
||||
pub mod dialogs;
|
||||
pub mod history;
|
||||
pub mod instant;
|
||||
pub mod link;
|
||||
pub mod login;
|
||||
pub mod markup;
|
||||
pub mod media;
|
||||
pub mod members;
|
||||
pub mod membership;
|
||||
pub mod messages;
|
||||
pub mod notify;
|
||||
pub mod peers;
|
||||
pub mod pins;
|
||||
pub mod polls;
|
||||
pub mod profile;
|
||||
pub mod reactions;
|
||||
pub mod search;
|
||||
pub mod send;
|
||||
pub mod threads;
|
||||
pub mod updates;
|
||||
pub mod session;
|
||||
|
||||
use grammers_mtsender::ConnectionParams;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What the rest of the daemon needs to act on a connected account: its client, its session (to turn a conversation
|
||||
/// into something Telegram will accept, an id with the authority to use it) and its own name.
|
||||
#[derive(Clone)]
|
||||
pub struct Handle {
|
||||
pub client: grammers_client::Client,
|
||||
pub session: Arc<session::FileSession>,
|
||||
pub own: messages::Own,
|
||||
/// What people are called, by Telegram's number for them, so a typing notice can say who. Filled as people are
|
||||
/// met in the conversation list and in messages.
|
||||
pub names: Arc<std::sync::Mutex<std::collections::HashMap<i64, String>>>,
|
||||
}
|
||||
|
||||
/// The name Telegram shows for this app's sessions: `moho` and the kind of machine it runs on.
|
||||
pub fn device_model() -> String {
|
||||
format!("moho ({} {})", std::env::consts::OS, std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
/// How every connection of an account introduces itself.
|
||||
///
|
||||
/// `proxy` is the SOCKS5 address of the account's route (Tor or the proxy in the network settings), if it has one.
|
||||
pub fn connection_params(proxy: Option<&str>) -> ConnectionParams {
|
||||
ConnectionParams {
|
||||
device_model: device_model(),
|
||||
// Moho's own version, which is the daemon's.
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
proxy_url: proxy.map(str::to_string),
|
||||
..ConnectionParams::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_identity_says_what_this_is_and_is_the_same_each_time() {
|
||||
let a = connection_params(None);
|
||||
let b = connection_params(None);
|
||||
assert!(a.device_model.starts_with("moho ("), "{}", a.device_model);
|
||||
assert_eq!((a.device_model.as_str(), a.app_version.as_str()), (b.device_model.as_str(), b.app_version.as_str()));
|
||||
assert_eq!(a.app_version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(!a.system_version.is_empty() && !a.lang_code.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//! Muting a conversation on the account itself.
|
||||
//!
|
||||
//! Telegram keeps "mute until" per chat with the account, so a chat silenced on the phone is silent here and the
|
||||
//! other way round. It is read off each dialog and from the update Telegram sends when it changes, and written when
|
||||
//! the window asks.
|
||||
|
||||
use super::peers;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerId;
|
||||
|
||||
/// "Forever", as Telegram's own apps write it.
|
||||
const FOREVER: i32 = i32::MAX;
|
||||
|
||||
/// Whether notification settings say the chat is silent at `now`.
|
||||
pub fn silent(settings: &tl::enums::PeerNotifySettings, now: i64) -> bool {
|
||||
let tl::enums::PeerNotifySettings::Settings(s) = settings;
|
||||
s.mute_until.is_some_and(|until| i64::from(until) > now)
|
||||
}
|
||||
|
||||
/// A chat's settings changed (on another of the account's apps, or here): the buffer follows.
|
||||
pub fn changed(state: &AppState, account_id: &str, update: &tl::types::UpdateNotifySettings) {
|
||||
let tl::enums::NotifyPeer::Peer(p) = &update.peer else { return };
|
||||
let remote = peers::remote_id(PeerId::from(p.peer.clone()));
|
||||
let Some(buffer) = state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(remote.as_str())) else { return };
|
||||
state.runtime.set_silenced(state, &buffer.id, silent(&update.notify_settings, chrono::Utc::now().timestamp()));
|
||||
}
|
||||
|
||||
/// Mutes a chat, or takes the mute off.
|
||||
pub async fn set_muted(state: &AppState, buffer: &Buffer, muted: bool) -> Result<(), String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let settings = tl::types::InputPeerNotifySettings {
|
||||
show_previews: None,
|
||||
silent: None,
|
||||
mute_until: Some(if muted { FOREVER } else { 0 }),
|
||||
sound: None,
|
||||
stories_muted: None,
|
||||
stories_hide_sender: None,
|
||||
stories_sound: None,
|
||||
};
|
||||
let peer = tl::enums::InputNotifyPeer::Peer(tl::types::InputNotifyPeer { peer: peer.into() });
|
||||
handle.client.invoke(&tl::functions::account::UpdateNotifySettings { peer, settings: settings.into() }).await.map_err(|e| explain(&e))?;
|
||||
state.runtime.set_silenced(state, &buffer.id, muted);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(mute_until: Option<i32>) -> tl::enums::PeerNotifySettings {
|
||||
tl::enums::PeerNotifySettings::Settings(tl::types::PeerNotifySettings {
|
||||
show_previews: None,
|
||||
silent: None,
|
||||
mute_until,
|
||||
ios_sound: None,
|
||||
android_sound: None,
|
||||
other_sound: None,
|
||||
stories_muted: None,
|
||||
stories_hide_sender: None,
|
||||
stories_ios_sound: None,
|
||||
stories_android_sound: None,
|
||||
stories_other_sound: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chat_is_silent_until_its_mute_runs_out() {
|
||||
assert!(silent(&settings(Some(FOREVER)), 1_790_000_000));
|
||||
assert!(silent(&settings(Some(1_790_000_100)), 1_790_000_000));
|
||||
assert!(!silent(&settings(Some(1_789_999_000)), 1_790_000_000));
|
||||
assert!(!silent(&settings(Some(0)), 1_790_000_000));
|
||||
assert!(!silent(&settings(None), 1_790_000_000));
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
//! A Telegram conversation as a buffer: its name, its kind, its heading in the list.
|
||||
//!
|
||||
//! People, groups, supergroups, channels and bots are all "peers". A person (or a bot) is a direct message; a group or
|
||||
//! a channel is a channel. The list is headed Direct Messages, Groups and Channels, as Twitch's is headed Channels and
|
||||
//! Direct Messages (see `runtime.rs fixed_category`).
|
||||
//!
|
||||
//! The buffer is named for the conversation, and the buffer's name is what its history is filed under, so it must not
|
||||
//! change when somebody renames themselves: a buffer is found by the peer's own id (Telegram's "bot API dialog id",
|
||||
//! kept as the buffer's `remote_id`), and keeps the name it was first given. Two people with the same name are told
|
||||
//! apart by their `@username`, or failing that by the end of their number.
|
||||
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::peer::Peer;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerId;
|
||||
|
||||
pub const DIRECT_MESSAGES: &str = "Direct Messages";
|
||||
pub const BOTS: &str = "Bots";
|
||||
pub const GROUPS: &str = "Groups";
|
||||
pub const CHANNELS: &str = "Channels";
|
||||
|
||||
/// The id Telegram's own tools (and the bot API) call a peer by: a person's number, minus a group's, and a
|
||||
/// supergroup's or channel's with `-100` in front.
|
||||
pub fn remote_id(peer: PeerId) -> String {
|
||||
peer.bot_api_dialog_id().map(|id| id.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The peer a buffer's `remote_id` names.
|
||||
pub fn peer_id_of(remote_id: &str) -> Option<PeerId> {
|
||||
PeerId::from_bot_api_dialog_id(remote_id.parse().ok()?)
|
||||
}
|
||||
|
||||
pub fn kind_of(peer: &Peer) -> &'static str {
|
||||
match peer {
|
||||
Peer::User(_) => "dm",
|
||||
_ => "channel",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_of(peer: &Peer) -> &'static str {
|
||||
match peer {
|
||||
// A bot is a conversation with a program, not a person: its own heading (it is still a direct chat).
|
||||
Peer::User(user) if user.is_bot() => BOTS,
|
||||
Peer::User(_) => DIRECT_MESSAGES,
|
||||
Peer::Channel(_) => CHANNELS,
|
||||
_ => GROUPS,
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph in front of a conversation's name: a horn for a broadcast channel, a robot for a bot.
|
||||
pub fn name_icon_of(peer: &Peer) -> Option<&'static str> {
|
||||
match peer {
|
||||
Peer::Channel(channel) if channel.raw.broadcast => Some("campaign"),
|
||||
Peer::User(user) if user.is_bot() => Some("smart_toy"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the conversation is called: the person's name, the group's title, or "Saved Messages" for the account's own.
|
||||
pub fn title_of(peer: &Peer) -> String {
|
||||
match peer {
|
||||
Peer::User(user) if user.is_self() => "Saved Messages".to_string(),
|
||||
Peer::User(user) => {
|
||||
let name = user.full_name();
|
||||
if !name.trim().is_empty() {
|
||||
name
|
||||
} else if let Some(username) = user.username() {
|
||||
username.to_string()
|
||||
} else {
|
||||
"Deleted account".to_string()
|
||||
}
|
||||
}
|
||||
other => other.name().filter(|n| !n.trim().is_empty()).unwrap_or("Unnamed chat").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A name for a buffer that no other conversation of the account has.
|
||||
pub fn unique_name(taken: &[(String, Option<String>)], remote_id: &str, title: &str, username: Option<&str>) -> String {
|
||||
let used = |name: &str| taken.iter().any(|(n, r)| n == name && r.as_deref() != Some(remote_id));
|
||||
if !used(title) {
|
||||
return title.to_string();
|
||||
}
|
||||
if let Some(username) = username.filter(|u| !u.is_empty()) {
|
||||
let with = format!("{title} (@{username})");
|
||||
if !used(&with) {
|
||||
return with;
|
||||
}
|
||||
}
|
||||
let digits: String = remote_id.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
let tail = &digits[digits.len().saturating_sub(4)..];
|
||||
format!("{title} ·{tail}")
|
||||
}
|
||||
|
||||
/// The conversation's buffer, made if it is not there yet.
|
||||
pub fn buffer_for(state: &AppState, account_id: &str, peer: &Peer) -> Buffer {
|
||||
let remote = remote_id(peer.id());
|
||||
let mine: Vec<Buffer> = state.runtime.list_buffers().into_iter().filter(|b| b.account_id == account_id).collect();
|
||||
if let Some(found) = mine.iter().find(|b| b.remote_id.as_deref() == Some(remote.as_str())) {
|
||||
return found.clone();
|
||||
}
|
||||
let taken: Vec<(String, Option<String>)> = mine.iter().map(|b| (b.name.clone(), b.remote_id.clone())).collect();
|
||||
let name = unique_name(&taken, &remote, &title_of(peer), peer.username());
|
||||
let buffer = state.runtime.ensure_buffer(state, account_id, &name, kind_of(peer));
|
||||
state.runtime.set_buffer_remote_id(state, &buffer.id, &remote);
|
||||
state.runtime.set_buffer_category(state, &buffer.id, Some(category_of(peer)), 0);
|
||||
state.runtime.set_buffer_name_icon(state, &buffer.id, name_icon_of(peer));
|
||||
state.runtime.get_buffer(&buffer.id).unwrap_or(buffer)
|
||||
}
|
||||
|
||||
|
||||
/// What the window says in place of the message box where the account may not write: the words every service uses.
|
||||
pub const NO_SEND_PERMISSION: &str = "You do not have permission to send messages in this channel.";
|
||||
|
||||
/// What decides whether an account may write in a group or a channel, read off what Telegram says about it.
|
||||
#[derive(Default, Clone, Debug, PartialEq)]
|
||||
pub struct Rights {
|
||||
/// A broadcast channel, where only those allowed to post may write.
|
||||
pub broadcast: bool,
|
||||
pub creator: bool,
|
||||
/// An administrator (of any kind) of this chat.
|
||||
pub admin: bool,
|
||||
/// An administrator who may post in a broadcast channel.
|
||||
pub may_post: bool,
|
||||
/// The account has left, or was removed.
|
||||
pub gone: bool,
|
||||
/// The chat was upgraded to a supergroup and no longer takes messages.
|
||||
pub deactivated: bool,
|
||||
/// This account in particular may not send.
|
||||
pub banned_here: bool,
|
||||
/// Nobody but administrators may send.
|
||||
pub members_banned: bool,
|
||||
}
|
||||
|
||||
/// Why the account cannot write in a chat, or `None` when it can.
|
||||
pub fn read_only_reason(r: &Rights) -> Option<&'static str> {
|
||||
if r.gone {
|
||||
return Some("You are not a member of this chat.");
|
||||
}
|
||||
if r.deactivated {
|
||||
return Some("This group was upgraded and no longer takes messages.");
|
||||
}
|
||||
let boss = r.creator || r.admin;
|
||||
if r.broadcast {
|
||||
return (!(r.creator || r.may_post)).then_some(NO_SEND_PERMISSION);
|
||||
}
|
||||
if r.banned_here || (r.members_banned && !boss) {
|
||||
return Some(NO_SEND_PERMISSION);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn sending_banned(rights: &Option<tl::enums::ChatBannedRights>, now: i64) -> bool {
|
||||
match rights {
|
||||
Some(tl::enums::ChatBannedRights::Rights(b)) => b.send_messages && (b.until_date == 0 || i64::from(b.until_date) > now),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_admin(rights: &Option<tl::enums::ChatAdminRights>) -> bool {
|
||||
rights.is_some()
|
||||
}
|
||||
|
||||
fn may_post(rights: &Option<tl::enums::ChatAdminRights>) -> bool {
|
||||
matches!(rights, Some(tl::enums::ChatAdminRights::Rights(a)) if a.post_messages)
|
||||
}
|
||||
|
||||
fn rights_of_channel(c: &tl::types::Channel, now: i64) -> Rights {
|
||||
Rights {
|
||||
broadcast: c.broadcast,
|
||||
creator: c.creator,
|
||||
admin: is_admin(&c.admin_rights),
|
||||
may_post: may_post(&c.admin_rights),
|
||||
gone: c.left,
|
||||
deactivated: false,
|
||||
banned_here: sending_banned(&c.banned_rights, now),
|
||||
members_banned: sending_banned(&c.default_banned_rights, now),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the account may do in a conversation. Only meaningful for a peer read from the dialog list: one that arrived
|
||||
/// with a message may be a "min" copy without the account's rights in it.
|
||||
pub fn rights_of(peer: &Peer, now: i64) -> Option<Rights> {
|
||||
match peer {
|
||||
Peer::Channel(channel) => Some(rights_of_channel(&channel.raw, now)),
|
||||
Peer::Group(group) => Some(match &group.raw {
|
||||
tl::enums::Chat::Chat(c) => Rights {
|
||||
creator: c.creator,
|
||||
admin: is_admin(&c.admin_rights),
|
||||
gone: c.left,
|
||||
deactivated: c.deactivated,
|
||||
members_banned: sending_banned(&c.default_banned_rights, now),
|
||||
..Default::default()
|
||||
},
|
||||
tl::enums::Chat::Channel(c) => rights_of_channel(c, now),
|
||||
// A chat that is gone, or that this account cannot see.
|
||||
_ => Rights { gone: true, ..Default::default() },
|
||||
}),
|
||||
Peer::User(user) if user.id().bare_id_unchecked() == 777000 => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason the window gives for a message box it does not show, for a conversation read from the dialog list.
|
||||
pub fn read_only_for(peer: &Peer, now: i64) -> Option<&'static str> {
|
||||
if let Peer::User(user) = peer {
|
||||
// Telegram's own service account sends its login codes and takes no reply.
|
||||
return (user.id().bare_id_unchecked() == 777000).then_some("Telegram does not take replies here.");
|
||||
}
|
||||
rights_of(peer, now).and_then(|r| read_only_reason(&r))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn two_people_with_one_name_are_told_apart_and_a_rename_never_makes_a_second_buffer() {
|
||||
let taken = vec![("Anna".to_string(), Some("111".to_string()))];
|
||||
assert_eq!(unique_name(&taken, "222", "Anna", None), "Anna ·222");
|
||||
assert_eq!(unique_name(&taken, "222", "Anna", Some("anna_k")), "Anna (@anna_k)");
|
||||
// The same person asking again is not a clash with themselves.
|
||||
assert_eq!(unique_name(&taken, "111", "Anna", None), "Anna");
|
||||
assert_eq!(unique_name(&taken, "333", "Boris", None), "Boris");
|
||||
assert_eq!(unique_name(&taken, "123456789", "Anna", None), "Anna ·6789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broadcast_channel_takes_messages_only_from_those_allowed_to_post() {
|
||||
let feed = Rights { broadcast: true, ..Default::default() };
|
||||
assert_eq!(read_only_reason(&feed), Some(NO_SEND_PERMISSION));
|
||||
assert_eq!(read_only_reason(&Rights { admin: true, ..feed.clone() }), Some(NO_SEND_PERMISSION), "an administrator who may not post is a reader here");
|
||||
assert_eq!(read_only_reason(&Rights { admin: true, may_post: true, ..feed.clone() }), None);
|
||||
assert_eq!(read_only_reason(&Rights { creator: true, ..feed }), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_takes_messages_unless_this_account_or_every_member_is_banned_from_sending() {
|
||||
assert_eq!(read_only_reason(&Rights::default()), None);
|
||||
assert_eq!(read_only_reason(&Rights { banned_here: true, ..Default::default() }), Some(NO_SEND_PERMISSION));
|
||||
assert_eq!(read_only_reason(&Rights { members_banned: true, ..Default::default() }), Some(NO_SEND_PERMISSION));
|
||||
assert_eq!(read_only_reason(&Rights { members_banned: true, admin: true, ..Default::default() }), None, "administrators may still send where members may not");
|
||||
assert!(read_only_reason(&Rights { gone: true, ..Default::default() }).unwrap().contains("not a member"));
|
||||
assert!(read_only_reason(&Rights { deactivated: true, ..Default::default() }).unwrap().contains("upgraded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ban_on_sending_that_has_run_out_is_not_one() {
|
||||
use tl::types::ChatBannedRights as B;
|
||||
let banned = |until| Some(tl::enums::ChatBannedRights::Rights(B { send_messages: true, until_date: until, view_messages: false, send_media: false, send_stickers: false, send_gifs: false, send_games: false, send_inline: false, embed_links: false, send_polls: false, change_info: false, invite_users: false, pin_messages: false, manage_topics: false, send_photos: false, send_videos: false, send_roundvideos: false, send_audios: false, send_voices: false, send_docs: false, send_plain: false, edit_rank: false, manage_linked_peers: false, send_reactions: false }));
|
||||
assert!(sending_banned(&banned(0), 1000), "no end is for ever");
|
||||
assert!(sending_banned(&banned(2000), 1000));
|
||||
assert!(!sending_banned(&banned(500), 1000));
|
||||
assert!(!sending_banned(&None, 1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_goes_to_the_buffer_and_back() {
|
||||
let user = PeerId::user_unchecked(5173409493);
|
||||
assert_eq!(remote_id(user), "5173409493");
|
||||
assert_eq!(peer_id_of("5173409493"), Some(user));
|
||||
let channel = PeerId::channel_unchecked(1234567890);
|
||||
assert_eq!(remote_id(channel), "-1001234567890");
|
||||
assert_eq!(peer_id_of("-1001234567890"), Some(channel));
|
||||
assert_eq!(peer_id_of("not a number"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! A conversation's pinned messages: listed, pinned and unpinned.
|
||||
//!
|
||||
//! Telegram keeps the list on its side, so it is asked for when somebody opens the pins and again when an update says
|
||||
//! the pins changed. The answer has the shape the other services' pins have, so the window's banner and list work
|
||||
//! the same.
|
||||
|
||||
use super::messages;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How many pins are read: a conversation with more has more than anybody is going to scroll.
|
||||
const LIMIT: usize = 50;
|
||||
|
||||
pub async fn list(state: &AppState, buffer: &Buffer) -> Result<Value, String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let mut found = handle.client.search_messages(peer).filter(tl::enums::MessagesFilter::InputMessagesFilterPinned);
|
||||
let mut pinned: Vec<Value> = Vec::new();
|
||||
while pinned.len() < LIMIT {
|
||||
let Some(message) = found.next().await.map_err(|e| explain(&e))? else { break };
|
||||
let from = if message.outgoing() { handle.own.name.clone() } else { message.sender().map(messages::name_of).unwrap_or_else(|| buffer.name.clone()) };
|
||||
let body = messages::body_of(&message);
|
||||
pinned.push(json!({
|
||||
"id": message.id().to_string(),
|
||||
"bufferId": buffer.id,
|
||||
"from": from,
|
||||
"body": if body.trim().is_empty() { "(no text)".to_string() } else { body },
|
||||
"ts": message.date().as_second(),
|
||||
"kind": "chat",
|
||||
}));
|
||||
}
|
||||
let ids: Vec<String> = pinned.iter().filter_map(|m| m["id"].as_str().map(str::to_string)).collect();
|
||||
// Said as well as answered, so the header's count is right in every window watching this conversation.
|
||||
state.events.emit("pinnedMessages", json!({ "bufferId": buffer.id, "pinned": ids }));
|
||||
Ok(json!({ "bufferId": buffer.id, "pinned": pinned }))
|
||||
}
|
||||
|
||||
pub async fn set(state: &AppState, buffer: &Buffer, msg_id: &str, pinned: bool) -> Result<(), String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message cannot be pinned".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
if pinned {
|
||||
handle.client.pin_message(peer, id).await.map_err(|e| explain(&e))?;
|
||||
} else {
|
||||
handle.client.unpin_message(peer, id).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
//! Polls: a question with answers, shown as the card over the conversation and voted in from it.
|
||||
//!
|
||||
//! A poll is part of a message. When one that is still open is read (live or from history) it is announced as the
|
||||
//! conversation's poll card; Telegram sends an update whenever the tally moves, and the card is drawn again. Only the
|
||||
//! newest open poll has the card (a conversation has one slot for it); a closed poll stays in the log as its label.
|
||||
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Which message each poll is, by Telegram's id for the poll, for an update that names only the poll.
|
||||
fn registry() -> &'static Mutex<HashMap<i64, (String, i32)>> {
|
||||
static R: std::sync::OnceLock<Mutex<HashMap<i64, (String, i32)>>> = std::sync::OnceLock::new();
|
||||
R.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
fn text_of(t: &tl::enums::TextWithEntities) -> String {
|
||||
let tl::enums::TextWithEntities::Entities(t) = t;
|
||||
t.text.clone()
|
||||
}
|
||||
|
||||
/// An answer's id as text: Telegram names an answer by a few bytes.
|
||||
pub fn option_id(bytes: &[u8]) -> String {
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// The card for a poll, from its definition and its tally.
|
||||
pub fn card(buffer_id: &str, msg_id: i32, poll: &tl::types::Poll, results: &tl::types::PollResults) -> Value {
|
||||
let tally: HashMap<Vec<u8>, (i32, bool)> = results
|
||||
.results
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|r| {
|
||||
let tl::enums::PollAnswerVoters::Voters(v) = r;
|
||||
(v.option.clone(), (v.voters.unwrap_or(0), v.chosen))
|
||||
})
|
||||
.collect();
|
||||
let mut voted: Option<String> = None;
|
||||
let options: Vec<Value> = poll
|
||||
.answers
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let tl::enums::PollAnswer::Answer(a) = a else { return json!({}) };
|
||||
let (votes, chosen) = tally.get(&a.option).copied().unwrap_or((0, false));
|
||||
if chosen {
|
||||
voted = Some(option_id(&a.option));
|
||||
}
|
||||
json!({ "id": option_id(&a.option), "label": text_of(&a.text), "votes": votes })
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"bufferId": buffer_id,
|
||||
"kind": "poll",
|
||||
"id": msg_id.to_string(),
|
||||
"title": text_of(&poll.question),
|
||||
"options": options,
|
||||
// A poll has no clock the card could count: it is open until it is closed.
|
||||
"open": !poll.closed,
|
||||
"duration": 0,
|
||||
"remaining": 0,
|
||||
"resultDisplayDuration": 0,
|
||||
"hasVoted": voted.is_some(),
|
||||
"votedOptionId": voted,
|
||||
"noEnd": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shows a poll as the conversation's card, and remembers which message it is.
|
||||
pub fn announce(state: &AppState, buffer_id: &str, msg_id: i32, poll: &tl::types::Poll, results: &tl::types::PollResults) {
|
||||
registry().lock().unwrap_or_else(|e| e.into_inner()).insert(poll.id, (buffer_id.to_string(), msg_id));
|
||||
if poll.closed {
|
||||
return;
|
||||
}
|
||||
let card = card(buffer_id, msg_id, poll, results);
|
||||
state.runtime.set_live_card(buffer_id, "poll", card.clone());
|
||||
let ts = chrono::Utc::now().timestamp();
|
||||
if let Err(e) = state.store.record_live_card(buffer_id, "poll", &msg_id.to_string(), card["title"].as_str().unwrap_or(""), &card.to_string(), ts) {
|
||||
tracing::debug!("telegram: keeping the poll: {e:#}");
|
||||
}
|
||||
state.events.emit("pollCard", json!({ "bufferId": buffer_id, "kind": "poll", "poll": card }));
|
||||
}
|
||||
|
||||
/// The tally of a poll moved.
|
||||
pub fn update(state: &AppState, update: &tl::types::UpdateMessagePoll) {
|
||||
let Some((buffer_id, msg_id)) = registry().lock().unwrap_or_else(|e| e.into_inner()).get(&update.poll_id).cloned() else { return };
|
||||
let tl::enums::PollResults::Results(results) = &update.results;
|
||||
// A poll that closed is drawn as it ended; one whose definition came with the update is redrawn from it.
|
||||
match &update.poll {
|
||||
Some(tl::enums::Poll::Poll(poll)) => announce_any(state, &buffer_id, msg_id, poll, results),
|
||||
None => {
|
||||
if let Some(mut live) = state.runtime.live_card(&buffer_id, "poll").filter(|c| c["id"].as_str() == Some(&msg_id.to_string())) {
|
||||
let mut chosen_id: Option<String> = None;
|
||||
if let Some(options) = live["options"].as_array_mut() {
|
||||
for option in options.iter_mut() {
|
||||
let id = option["id"].as_str().unwrap_or("").to_string();
|
||||
let found = results.results.iter().flatten().find_map(|r| {
|
||||
let tl::enums::PollAnswerVoters::Voters(v) = r;
|
||||
(option_id(&v.option) == id).then_some((v.voters.unwrap_or(0), v.chosen))
|
||||
});
|
||||
if let Some((votes, chosen)) = found {
|
||||
option["votes"] = json!(votes);
|
||||
if chosen {
|
||||
chosen_id = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = chosen_id {
|
||||
live["votedOptionId"] = json!(id);
|
||||
live["hasVoted"] = json!(true);
|
||||
}
|
||||
state.runtime.set_live_card(&buffer_id, "poll", live.clone());
|
||||
state.events.emit("pollCard", json!({ "bufferId": buffer_id, "kind": "poll", "poll": live }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Redraws a poll that may have closed: the card says so rather than vanishing.
|
||||
fn announce_any(state: &AppState, buffer_id: &str, msg_id: i32, poll: &tl::types::Poll, results: &tl::types::PollResults) {
|
||||
let card = card(buffer_id, msg_id, poll, results);
|
||||
state.runtime.set_live_card(buffer_id, "poll", card.clone());
|
||||
state.events.emit("pollCard", json!({ "bufferId": buffer_id, "kind": "poll", "poll": card }));
|
||||
}
|
||||
|
||||
/// Votes for an answer in the poll the conversation's card shows.
|
||||
pub async fn vote(state: &AppState, buffer: &Buffer, answer: &str) -> Result<(), String> {
|
||||
let card = state.runtime.live_card(&buffer.id, "poll").ok_or_else(|| "there is no poll open here".to_string())?;
|
||||
let msg_id: i32 = card["id"].as_str().and_then(|i| i.parse().ok()).ok_or_else(|| "that poll cannot be voted in".to_string())?;
|
||||
let option = hex::decode(answer).map_err(|_| "that is not one of the poll's answers".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
handle.client.invoke(&tl::functions::messages::SendVote { peer: peer.into(), msg_id, options: vec![option] }).await.map_err(|e| explain(&e))?;
|
||||
// Said at once; Telegram's own update with the new tally follows.
|
||||
let mut live = card;
|
||||
live["hasVoted"] = json!(true);
|
||||
live["votedOptionId"] = json!(answer);
|
||||
if let Some(options) = live["options"].as_array_mut() {
|
||||
for o in options.iter_mut() {
|
||||
if o["id"].as_str() == Some(answer) {
|
||||
o["votes"] = json!(o["votes"].as_i64().unwrap_or(0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.runtime.set_live_card(&buffer.id, "poll", live.clone());
|
||||
state.events.emit("pollCard", json!({ "bufferId": buffer.id, "kind": "poll", "poll": live }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Telegram's limits on a poll: a question of 255 characters, answers of 100, two to ten of them.
|
||||
const QUESTION_MAX: usize = 255;
|
||||
const ANSWER_MAX: usize = 100;
|
||||
const ANSWERS: std::ops::RangeInclusive<usize> = 2..=10;
|
||||
|
||||
/// What a poll somebody is starting is, checked and as Telegram's own type. `disclosed` is whether the running
|
||||
/// count shows before it closes.
|
||||
pub fn build(question: &str, answers: &[String], disclosed: bool) -> Result<tl::types::Poll, String> {
|
||||
let question = question.trim();
|
||||
let answers: Vec<&str> = answers.iter().map(|a| a.trim()).filter(|a| !a.is_empty()).collect();
|
||||
if question.is_empty() {
|
||||
return Err("A poll needs a question".to_string());
|
||||
}
|
||||
if question.chars().count() > QUESTION_MAX {
|
||||
return Err(format!("The question can be at most {QUESTION_MAX} characters"));
|
||||
}
|
||||
if !ANSWERS.contains(&answers.len()) {
|
||||
return Err("Telegram polls have between 2 and 10 answers".to_string());
|
||||
}
|
||||
if answers.iter().any(|a| a.chars().count() > ANSWER_MAX) {
|
||||
return Err(format!("An answer can be at most {ANSWER_MAX} characters"));
|
||||
}
|
||||
let plain = |t: &str| tl::enums::TextWithEntities::Entities(tl::types::TextWithEntities { text: t.to_string(), entities: Vec::new() });
|
||||
Ok(tl::types::Poll {
|
||||
id: 0,
|
||||
closed: false,
|
||||
// Anonymous, as Telegram's own apps start one.
|
||||
public_voters: false,
|
||||
multiple_choice: false,
|
||||
quiz: false,
|
||||
open_answers: false,
|
||||
revoting_disabled: false,
|
||||
shuffle_answers: false,
|
||||
hide_results_until_close: !disclosed,
|
||||
creator: false,
|
||||
subscribers_only: false,
|
||||
question: plain(question),
|
||||
answers: answers.iter().enumerate().map(|(i, a)| tl::enums::PollAnswer::Answer(tl::types::PollAnswer { text: plain(a), option: vec![i as u8], media: None, added_by: None, date: None })).collect(),
|
||||
close_period: None,
|
||||
close_date: None,
|
||||
hash: 0,
|
||||
countries_iso2: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a poll in a conversation.
|
||||
pub async fn start(state: &AppState, buffer: &Buffer, question: &str, answers: &[String], disclosed: bool) -> Result<(), String> {
|
||||
let poll = build(question, answers, disclosed)?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let media = tl::types::InputMediaPoll { poll: tl::enums::Poll::Poll(poll), correct_answers: None, attached_media: None, solution: None, solution_entities: None, solution_media: None };
|
||||
let input = grammers_client::message::InputMessage::new().media(tl::enums::InputMedia::Poll(Box::new(media)));
|
||||
let sent = handle.client.send_message(peer, input).await.map_err(|e| explain(&e))?;
|
||||
super::messages::record(state, &buffer.account_id, &handle.own, &sent);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(s: &str) -> tl::enums::TextWithEntities {
|
||||
tl::enums::TextWithEntities::Entities(tl::types::TextWithEntities { text: s.into(), entities: vec![] })
|
||||
}
|
||||
|
||||
fn answer(label: &str, option: &[u8]) -> tl::enums::PollAnswer {
|
||||
tl::enums::PollAnswer::Answer(tl::types::PollAnswer { text: text(label), option: option.to_vec(), media: None, added_by: None, date: None })
|
||||
}
|
||||
|
||||
fn poll(closed: bool) -> tl::types::Poll {
|
||||
tl::types::Poll {
|
||||
id: 77,
|
||||
closed,
|
||||
public_voters: false,
|
||||
multiple_choice: false,
|
||||
quiz: false,
|
||||
open_answers: false,
|
||||
revoting_disabled: false,
|
||||
shuffle_answers: false,
|
||||
hide_results_until_close: false,
|
||||
creator: false,
|
||||
subscribers_only: false,
|
||||
question: text("Lunch?"),
|
||||
answers: vec![answer("Pizza", b"0"), answer("Soup", b"1")],
|
||||
close_period: None,
|
||||
close_date: None,
|
||||
hash: 0,
|
||||
countries_iso2: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn voters(option: &[u8], n: i32, chosen: bool) -> tl::enums::PollAnswerVoters {
|
||||
tl::enums::PollAnswerVoters::Voters(tl::types::PollAnswerVoters { chosen, correct: false, option: option.to_vec(), voters: Some(n), recent_voters: None })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poll_that_is_started_is_checked() {
|
||||
let a = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let ok = build(" Lunch? ", &a(&["Pizza", " Soup ", ""]), false).unwrap();
|
||||
assert_eq!(ok.answers.len(), 2, "an empty answer is not one");
|
||||
assert!(ok.hide_results_until_close);
|
||||
assert!(!ok.public_voters);
|
||||
assert!(build("", &a(&["a", "b"]), true).is_err());
|
||||
assert!(build("Q", &a(&["only"]), true).is_err());
|
||||
assert!(build("Q", &a(&["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]), true).is_err());
|
||||
assert!(build(&"x".repeat(256), &a(&["a", "b"]), true).is_err());
|
||||
assert!(!build("Q", &a(&["a", "b"]), true).unwrap().hide_results_until_close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poll_becomes_a_card_with_counts_and_the_accounts_own_choice() {
|
||||
let results = tl::types::PollResults { min: false, has_unread_votes: false, can_view_stats: false, results: Some(vec![voters(b"0", 3, false), voters(b"1", 5, true)]), total_voters: Some(8), recent_voters: None, solution: None, solution_entities: None, solution_media: None };
|
||||
let card = card("telegram:1|Lunch", 42, &poll(false), &results);
|
||||
assert_eq!((card["title"].as_str(), card["id"].as_str(), card["open"].as_bool()), (Some("Lunch?"), Some("42"), Some(true)));
|
||||
assert_eq!(card["options"][0]["votes"], 3);
|
||||
assert_eq!(card["options"][1]["votes"], 5);
|
||||
assert_eq!((card["hasVoted"].as_bool(), card["votedOptionId"].as_str()), (Some(true), Some(option_id(b"1").as_str())));
|
||||
let closed = super::card("b", 1, &poll(true), &results);
|
||||
assert_eq!(closed["open"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poll_nobody_voted_in_has_zeros_and_no_choice() {
|
||||
let results = tl::types::PollResults { min: false, has_unread_votes: false, can_view_stats: false, results: None, total_voters: None, recent_voters: None, solution: None, solution_entities: None, solution_media: None };
|
||||
let card = card("b", 1, &poll(false), &results);
|
||||
assert_eq!(card["options"][0]["votes"], 0);
|
||||
assert_eq!(card["hasVoted"], false);
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
//! The card for a person: their name, handle, picture, what they wrote about themselves and when they were last seen.
|
||||
|
||||
use super::media;
|
||||
use super::messages;
|
||||
use super::peers;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::profile;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::peer::{Peer, User};
|
||||
use grammers_client::tl;
|
||||
use grammers_session::Session;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// What they were last seen doing, in the card's words, from Telegram's coarse status.
|
||||
pub fn presence(status: &tl::enums::UserStatus) -> (Option<&'static str>, Option<i64>) {
|
||||
use tl::enums::UserStatus as S;
|
||||
match status {
|
||||
S::Online(_) => (Some("online"), None),
|
||||
S::Offline(o) => (Some("offline"), Some(i64::from(o.was_online))),
|
||||
S::Recently(_) => (Some("offline"), None),
|
||||
S::LastWeek(_) | S::LastMonth(_) => (Some("offline"), None),
|
||||
S::Empty => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// How Telegram words its coarse "last seen" when it will not give a time.
|
||||
fn coarse(status: &tl::enums::UserStatus) -> Option<&'static str> {
|
||||
use tl::enums::UserStatus as S;
|
||||
match status {
|
||||
S::Recently(_) => Some("recently"),
|
||||
S::LastWeek(_) => Some("within a week"),
|
||||
S::LastMonth(_) => Some("within a month"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The card for the person a conversation is with, or a member of a group the window asked about.
|
||||
pub async fn build(state: &AppState, account_id: &str, buffer_id: &str, who: &str, user_id: &str) -> Value {
|
||||
let mut card = profile::pending("telegram", account_id, who);
|
||||
card["pending"] = json!(false);
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else { return card };
|
||||
// A direct message is with one person; in a group the member asked about is named by number.
|
||||
let target = if buffer.kind == "dm" { buffer.remote_id.clone() } else { Some(user_id.to_string()) };
|
||||
let Some(target) = target.and_then(|t| peers::peer_id_of(&t)) else { return card };
|
||||
let Ok((handle, _)) = peer_for(state, &buffer).await else { return card };
|
||||
let Some(peer) = handle.session.peer_ref(target).await.ok().flatten() else { return card };
|
||||
card["id"] = json!(target.bare_id_unchecked().to_string());
|
||||
let full = handle.client.invoke(&tl::functions::users::GetFullUser { id: peer.into() }).await;
|
||||
let tl::enums::users::UserFull::Full(full) = match full {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
tracing::debug!("telegram profile: {}", explain(&e));
|
||||
return card;
|
||||
}
|
||||
};
|
||||
let tl::enums::UserFull::Full(info) = &full.full_user;
|
||||
if let Some(user) = full.users.iter().find(|u| u.id() == target.bare_id_unchecked()).cloned() {
|
||||
let user = User::from_raw(&handle.client, user);
|
||||
let as_peer = Peer::User(user.clone());
|
||||
card["name"] = json!(messages::name_of(&as_peer));
|
||||
if let Some(username) = user.username() {
|
||||
card["handle"] = json!(format!("@{username}"));
|
||||
}
|
||||
profile::set(&mut card, "avatarUrl", media::avatar_url(handle.own.user_id, &as_peer).map(|u| json!(u)));
|
||||
let (status, seen) = presence(user.status());
|
||||
profile::set(&mut card, "status", status.map(|s| json!(s)));
|
||||
profile::set(&mut card, "lastActiveTs", seen.map(|t| json!(t)));
|
||||
if let Some(words) = coarse(user.status()) {
|
||||
profile::note(&mut card, "Last seen", words);
|
||||
}
|
||||
if user.is_bot() {
|
||||
profile::note(&mut card, "Account", "A bot");
|
||||
}
|
||||
if user.verified() {
|
||||
profile::note(&mut card, "Account", "Verified by Telegram");
|
||||
}
|
||||
if let Some(phone) = user.phone() {
|
||||
profile::note(&mut card, "Phone", format!("+{phone}"));
|
||||
}
|
||||
}
|
||||
if let Some(about) = &info.about {
|
||||
profile::note(&mut card, "About", about.clone());
|
||||
}
|
||||
if let Some(tl::enums::Birthday::Birthday(b)) = &info.birthday {
|
||||
profile::note(&mut card, "Birthday", match b.year {
|
||||
Some(year) => format!("{}.{}.{year}", b.day, b.month),
|
||||
None => format!("{}.{}", b.day, b.month),
|
||||
});
|
||||
}
|
||||
if info.common_chats_count > 0 {
|
||||
profile::note(&mut card, "Groups in common", info.common_chats_count.to_string());
|
||||
}
|
||||
card
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn last_seen_is_a_time_when_telegram_gives_one_and_a_word_when_it_does_not() {
|
||||
let offline = tl::enums::UserStatus::Offline(tl::types::UserStatusOffline { was_online: 1790000000 });
|
||||
assert_eq!(presence(&offline), (Some("offline"), Some(1790000000)));
|
||||
assert_eq!(presence(&tl::enums::UserStatus::Online(tl::types::UserStatusOnline { expires: 1 })), (Some("online"), None));
|
||||
let recently = tl::enums::UserStatus::Recently(tl::types::UserStatusRecently { by_me: false });
|
||||
assert_eq!(presence(&recently), (Some("offline"), None));
|
||||
assert_eq!(coarse(&recently), Some("recently"));
|
||||
assert_eq!(coarse(&offline), None);
|
||||
assert_eq!(presence(&tl::enums::UserStatus::Empty), (None, None));
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
//! Reactions: the emoji people put on a message, and the account's own.
|
||||
//!
|
||||
//! Telegram sends a message with its reactions as a list of (reaction, count, whether the account chose it), and an
|
||||
//! update when they change. A reaction is an ordinary emoji, a custom emoji (drawn from Telegram's sticker sets,
|
||||
//! which moho does not draw yet, so it is counted under a star) or a paid star.
|
||||
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::{Buffer, Reaction};
|
||||
use crate::state::AppState;
|
||||
use grammers_client::message::InputReactions;
|
||||
use grammers_client::tl;
|
||||
|
||||
/// What stands for a reaction moho cannot draw: custom emoji and paid stars.
|
||||
const STAND_IN: &str = "⭐";
|
||||
|
||||
/// A message's reactions as moho's list.
|
||||
pub fn from_tl(reactions: &tl::enums::MessageReactions) -> Vec<Reaction> {
|
||||
let tl::enums::MessageReactions::Reactions(r) = reactions;
|
||||
let mut out: Vec<Reaction> = Vec::new();
|
||||
for count in &r.results {
|
||||
let tl::enums::ReactionCount::Count(c) = count;
|
||||
let emoji = match &c.reaction {
|
||||
tl::enums::Reaction::Emoji(e) => e.emoticon.clone(),
|
||||
tl::enums::Reaction::CustomEmoji(_) | tl::enums::Reaction::Paid => STAND_IN.to_string(),
|
||||
tl::enums::Reaction::Empty => continue,
|
||||
};
|
||||
// Two kinds that share the stand-in are one pill.
|
||||
if let Some(existing) = out.iter_mut().find(|x| x.emoji == emoji) {
|
||||
existing.count += i64::from(c.count);
|
||||
existing.me |= c.chosen_order.is_some();
|
||||
} else {
|
||||
out.push(Reaction { emoji, count: i64::from(c.count), me: c.chosen_order.is_some(), animated: false });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Records what a message's reactions are now, and says so.
|
||||
pub fn apply(state: &AppState, buffer_id: &str, msg_id: &str, reactions: &[Reaction]) {
|
||||
match state.store.set_reactions(buffer_id, msg_id, reactions) {
|
||||
Ok(true) => state.events.emit("reactionsChanged", serde_json::json!({ "bufferId": buffer_id, "id": msg_id, "reactions": reactions })),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("telegram: recording reactions: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reactions the account itself has put on a message, as Telegram's own type.
|
||||
fn chosen(reactions: &[Reaction]) -> Vec<tl::enums::Reaction> {
|
||||
reactions.iter().filter(|r| r.me && r.emoji != STAND_IN).map(|r| tl::enums::Reaction::Emoji(tl::types::ReactionEmoji { emoticon: r.emoji.clone() })).collect()
|
||||
}
|
||||
|
||||
/// Puts an emoji on a message or takes the account's off. Telegram lets an ordinary account have one reaction on a
|
||||
/// message, so adding replaces the account's earlier one.
|
||||
pub async fn toggle(state: &AppState, buffer: &Buffer, msg_id: &str, emoji: &str, add: bool) -> Result<(), String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message cannot take reactions".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let stored = state.store.get_message(&buffer.id, msg_id).ok().flatten().map(|m| m.reactions).unwrap_or_default();
|
||||
let stored_again = stored.clone();
|
||||
let mut mine = chosen(&stored);
|
||||
let this = tl::enums::Reaction::Emoji(tl::types::ReactionEmoji { emoticon: emoji.to_string() });
|
||||
if add {
|
||||
mine = vec![this];
|
||||
} else {
|
||||
mine.retain(|r| *r != this);
|
||||
}
|
||||
let input: InputReactions = if mine.is_empty() { InputReactions::remove() } else { mine.into() };
|
||||
handle.client.send_reactions(peer, id, input).await.map_err(|e| explain(&e))?;
|
||||
// Telegram answers with the message's new state as an update, which is read below; this is the same fact said
|
||||
// at once so the pill does not wait on it.
|
||||
apply(state, &buffer.id, msg_id, &after_change(&stored_again, emoji, add));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The reactions a message has once the account has put an emoji on it (replacing its earlier one) or taken it off.
|
||||
pub fn after_change(stored: &[Reaction], emoji: &str, add: bool) -> Vec<Reaction> {
|
||||
let mut next: Vec<Reaction> = stored.to_vec();
|
||||
for r in next.iter_mut() {
|
||||
let own_ordinary = r.me && r.emoji != STAND_IN;
|
||||
if own_ordinary && (add || r.emoji == emoji) {
|
||||
r.me = false;
|
||||
r.count -= 1;
|
||||
}
|
||||
}
|
||||
next.retain(|r| r.count > 0);
|
||||
if add {
|
||||
match next.iter_mut().find(|r| r.emoji == emoji) {
|
||||
Some(r) => {
|
||||
r.count += 1;
|
||||
r.me = true;
|
||||
}
|
||||
None => next.push(Reaction { emoji: emoji.to_string(), count: 1, me: true, animated: false }),
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// A reactions update from Telegram.
|
||||
pub fn update(state: &AppState, account_id: &str, remote: &str, msg_id: i32, reactions: &tl::enums::MessageReactions) {
|
||||
let Some(buffer) = state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(remote)) else { return };
|
||||
apply(state, &buffer.id, &msg_id.to_string(), &from_tl(reactions));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn count(reaction: tl::enums::Reaction, n: i32, chosen: Option<i32>) -> tl::enums::ReactionCount {
|
||||
tl::enums::ReactionCount::Count(tl::types::ReactionCount { chosen_order: chosen, reaction, count: n })
|
||||
}
|
||||
|
||||
fn emoji(e: &str) -> tl::enums::Reaction {
|
||||
tl::enums::Reaction::Emoji(tl::types::ReactionEmoji { emoticon: e.into() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactions_become_pills_with_counts_and_whether_the_account_chose_them() {
|
||||
let results = vec![count(emoji("👍"), 5, Some(0)), count(emoji("🔥"), 2, None), count(tl::enums::Reaction::CustomEmoji(tl::types::ReactionCustomEmoji { document_id: 9 }), 3, None), count(tl::enums::Reaction::Paid, 4, None)];
|
||||
let reactions = tl::enums::MessageReactions::Reactions(tl::types::MessageReactions { min: false, can_see_list: false, reactions_as_tags: false, results, recent_reactions: None, top_reactors: None });
|
||||
let got = from_tl(&reactions);
|
||||
assert_eq!(got.iter().map(|r| (r.emoji.as_str(), r.count, r.me)).collect::<Vec<_>>(), vec![("👍", 5, true), ("🔥", 2, false), ("⭐", 7, false)], "two kinds drawn the same are one pill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_an_emoji_replaces_the_accounts_earlier_one_and_taking_it_off_leaves_the_rest() {
|
||||
let r = |e: &str, n: i64, me: bool| Reaction { emoji: e.into(), count: n, me, animated: false };
|
||||
let stored = vec![r("👍", 5, true), r("🔥", 2, false)];
|
||||
assert_eq!(after_change(&stored, "🔥", true), vec![r("👍", 4, false), r("🔥", 3, true)]);
|
||||
assert_eq!(after_change(&stored, "👍", false), vec![r("👍", 4, false), r("🔥", 2, false)]);
|
||||
assert_eq!(after_change(&[r("👍", 1, true)], "👍", false), Vec::<Reaction>::new(), "a pill nobody has any more goes");
|
||||
assert_eq!(after_change(&[], "😂", true), vec![r("😂", 1, true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_accounts_own_ordinary_reactions_are_carried_into_a_change() {
|
||||
let stored = vec![Reaction { emoji: "👍".into(), count: 5, me: true, animated: false }, Reaction { emoji: "🔥".into(), count: 2, me: false, animated: false }, Reaction { emoji: STAND_IN.into(), count: 1, me: true, animated: false }];
|
||||
assert_eq!(chosen(&stored), vec![emoji("👍")]);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! Searching on Telegram's side: in one conversation or in every one the account has.
|
||||
//!
|
||||
//! The window searches its own copy first and shows that at once; this adds what the account's history has that this
|
||||
//! client never read. A hit is answered as a message, and nothing is recorded: pressing one reads the messages around
|
||||
//! it (see `history::context`) the way a jump to anything old does.
|
||||
|
||||
use super::media;
|
||||
use super::messages;
|
||||
use super::send::{explain, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::message::Message;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// One hit as the window reads a message.
|
||||
fn hit(state: &AppState, account_id: &str, own_name: &str, own_id: i64, message: &Message) -> Option<Value> {
|
||||
let peer = message.peer()?;
|
||||
let buffer = super::peers::buffer_for(state, account_id, peer);
|
||||
let from = if message.outgoing() { own_name.to_string() } else { message.sender().map(messages::name_of).unwrap_or_else(|| super::peers::title_of(peer)) };
|
||||
let remote = buffer.remote_id.clone().unwrap_or_default();
|
||||
Some(json!({
|
||||
"id": message.id().to_string(),
|
||||
"bufferId": buffer.id,
|
||||
"from": from,
|
||||
"body": messages::body_of(message),
|
||||
"ts": message.date().as_second(),
|
||||
"kind": "chat",
|
||||
"isAction": false,
|
||||
"isHighlight": false,
|
||||
"edited": false,
|
||||
"isOwn": message.outgoing(),
|
||||
"attachments": media::attachments_of(own_id, &remote, message),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The matches for a query, in this conversation or across the account (`account` scope).
|
||||
pub async fn run(state: &AppState, buffer: &Buffer, query: &str, account_wide: bool, limit: usize) -> Result<Value, String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
if account_wide {
|
||||
let mut found = handle.client.search_all_messages().query(query);
|
||||
while results.len() < limit {
|
||||
let Some(message) = found.next().await.map_err(|e| explain(&e))? else { break };
|
||||
results.extend(hit(state, &buffer.account_id, &handle.own.name, handle.own.user_id, &message));
|
||||
}
|
||||
} else {
|
||||
let mut found = handle.client.search_messages(peer).query(query);
|
||||
while results.len() < limit {
|
||||
let Some(message) = found.next().await.map_err(|e| explain(&e))? else { break };
|
||||
results.extend(hit(state, &buffer.account_id, &handle.own.name, handle.own.user_id, &message));
|
||||
}
|
||||
}
|
||||
let names: std::collections::HashMap<String, String> = state.runtime.list_buffers().into_iter().filter(|b| b.account_id == buffer.account_id).map(|b| (b.id, b.name)).collect();
|
||||
let count = results.len();
|
||||
Ok(json!({ "results": results, "roomNames": names, "count": count }))
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
//! Saying things in a conversation: send, answer, edit, delete, "typing", "read".
|
||||
//!
|
||||
//! Each starts from the buffer: its `remote_id` names the peer, and the session turns that into the reference
|
||||
//! Telegram wants with it (an id with the authority to use it). A message that was sent is recorded here as it is
|
||||
//! returned, because Telegram does not send an account its own message back as an update.
|
||||
|
||||
use super::markup;
|
||||
use super::messages;
|
||||
use super::peers;
|
||||
use super::Handle;
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use crate::upload::{Phase, Progress};
|
||||
use grammers_client::media::{InputMedia, Uploaded};
|
||||
use grammers_client::message::InputMessage;
|
||||
use grammers_client::{tl, InvocationError};
|
||||
use grammers_session::types::PeerRef;
|
||||
use grammers_session::Session;
|
||||
|
||||
/// What Telegram allows in one message, counted the way it counts (UTF-16 units).
|
||||
pub const MAX_UNITS: usize = 4096;
|
||||
|
||||
/// Telegram's refusal, said so a person can act on it.
|
||||
pub fn explain(error: &InvocationError) -> String {
|
||||
match error {
|
||||
InvocationError::Rpc(rpc) => {
|
||||
let n = rpc.value.unwrap_or(0);
|
||||
match rpc.name.as_str() {
|
||||
"FLOOD_WAIT" | "FLOOD_PREMIUM_WAIT" => format!("Telegram says to wait {n} seconds before doing that again"),
|
||||
"SLOWMODE_WAIT" => format!("Slow mode is on here: wait {n} seconds between messages"),
|
||||
"CHAT_WRITE_FORBIDDEN" | "CHAT_SEND_PLAIN_FORBIDDEN" => peers::NO_SEND_PERMISSION.to_string(),
|
||||
"USER_BANNED_IN_CHANNEL" => "You are banned from sending messages in this chat".to_string(),
|
||||
"USER_IS_BLOCKED" => "This person has blocked you".to_string(),
|
||||
"USER_PRIVACY_RESTRICTED" => "This person's privacy settings do not let you message them".to_string(),
|
||||
"PEER_FLOOD" => "Telegram is limiting how many people this account may start a conversation with".to_string(),
|
||||
"MESSAGE_TOO_LONG" => format!("That message is too long: Telegram takes at most {MAX_UNITS} characters"),
|
||||
"MESSAGE_EMPTY" => "There is nothing to send".to_string(),
|
||||
"MESSAGE_NOT_MODIFIED" => "That is already what the message says".to_string(),
|
||||
"MESSAGE_EDIT_TIME_EXPIRED" => "Telegram no longer lets this message be edited".to_string(),
|
||||
"MESSAGE_ID_INVALID" | "MESSAGE_NOT_FOUND" => "That message is no longer there".to_string(),
|
||||
"CHAT_ADMIN_REQUIRED" => "Only an administrator may do that here".to_string(),
|
||||
"MEDIA_EMPTY" | "FILE_PART_INVALID" => "Telegram would not take that file".to_string(),
|
||||
"FILE_REFERENCE_EXPIRED" => "Telegram needs the message read again before that file can be downloaded".to_string(),
|
||||
other => format!("Telegram refused: {}", other.to_lowercase().replace('_', " ")),
|
||||
}
|
||||
}
|
||||
InvocationError::Io(e) => format!("Telegram could not be reached: {e}"),
|
||||
other => format!("{other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The account's connection and Telegram's reference to the conversation a buffer is.
|
||||
pub async fn peer_for(state: &AppState, buffer: &Buffer) -> Result<(Handle, PeerRef), String> {
|
||||
let handle = state.runtime.telegram_handle(&buffer.account_id).ok_or_else(|| "that Telegram account is not connected".to_string())?;
|
||||
let id = buffer.remote_id.as_deref().and_then(peers::peer_id_of).ok_or_else(|| "this is not a Telegram conversation".to_string())?;
|
||||
let peer = handle.session.peer_ref(id).await.ok().flatten().ok_or_else(|| "Telegram has not told this account about that conversation yet".to_string())?;
|
||||
Ok((handle, peer))
|
||||
}
|
||||
|
||||
fn units(text: &str) -> usize {
|
||||
text.encode_utf16().count()
|
||||
}
|
||||
|
||||
/// Text from the message box, marks taken out and entities in place, if Telegram would take it.
|
||||
pub fn checked(body: &str) -> Result<(String, Vec<tl::enums::MessageEntity>), String> {
|
||||
let (text, entities) = markup::parse(body);
|
||||
if text.trim().is_empty() {
|
||||
return Err("There is nothing to send".to_string());
|
||||
}
|
||||
if units(&text) > MAX_UNITS {
|
||||
return Err(format!("That message is too long: Telegram takes at most {MAX_UNITS} characters, and this has {}", units(&text)));
|
||||
}
|
||||
Ok((text, entities))
|
||||
}
|
||||
|
||||
/// The message box's text as a message.
|
||||
pub fn input_for(body: &str) -> Result<InputMessage, String> {
|
||||
let (text, entities) = checked(body)?;
|
||||
Ok(InputMessage::new().text(text).fmt_entities(entities))
|
||||
}
|
||||
|
||||
/// Sends a message, as an answer to another if `reply_to` names one.
|
||||
pub async fn send(state: &AppState, buffer: &Buffer, body: &str, reply_to: Option<&str>) -> Result<(), String> {
|
||||
let input = input_for(body)?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let input = input.reply_to(reply_to.and_then(|id| id.parse::<i32>().ok()));
|
||||
let sent = handle.client.send_message(peer, input).await.map_err(|e| explain(&e))?;
|
||||
messages::record(state, &buffer.account_id, &handle.own, &sent);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Changes what one of the account's own messages says.
|
||||
pub async fn edit(state: &AppState, buffer: &Buffer, msg_id: &str, body: &str) -> Result<(), String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message cannot be edited".to_string())?;
|
||||
let input = input_for(body)?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
handle.client.edit_message(peer, id, input).await.map_err(|e| explain(&e))?;
|
||||
let (text, _) = markup::parse(body);
|
||||
state.runtime.update_message(state, &buffer.id, msg_id, &text, &[], &[]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a message: for everyone where Telegram allows it, or only from this account's own view.
|
||||
pub async fn delete(state: &AppState, buffer: &Buffer, msg_id: &str, for_everyone: bool) -> Result<(), String> {
|
||||
let id: i32 = msg_id.parse().map_err(|_| "that message cannot be deleted".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
if for_everyone || peer.id.kind() == grammers_session::types::PeerKind::Channel {
|
||||
handle.client.delete_messages(peer, &[id]).await.map_err(|e| explain(&e))?;
|
||||
} else {
|
||||
handle.client.invoke(&tl::functions::messages::DeleteMessages { revoke: false, id: vec![id] }).await.map_err(|e| explain(&e))?;
|
||||
}
|
||||
state.runtime.delete_message(state, &buffer.id, msg_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Says the account is typing. Telegram lets it lapse after a few seconds, so there is no "stopped".
|
||||
pub async fn typing(state: &AppState, buffer: &Buffer) -> Result<(), String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
handle.client.action(peer).oneshot(tl::enums::SendMessageAction::SendMessageTypingAction).await.map_err(|e| explain(&e))
|
||||
}
|
||||
|
||||
/// Says everything in the conversation has been read, so its unread count goes down on the account's other apps.
|
||||
pub async fn mark_read(state: &AppState, buffer: &Buffer) -> Result<(), String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
handle.client.mark_as_read(peer).await.map_err(|e| explain(&e))
|
||||
}
|
||||
|
||||
|
||||
/// A reader that says how much of it has been read, so an upload can show a ring that fills.
|
||||
struct Counting<'a, R> {
|
||||
inner: R,
|
||||
done: u64,
|
||||
said: u64,
|
||||
base: u64,
|
||||
total: u64,
|
||||
report: &'a (dyn Fn(u64, u64) + Send + Sync),
|
||||
}
|
||||
|
||||
impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for Counting<'_, R> {
|
||||
fn poll_read(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> std::task::Poll<std::io::Result<()>> {
|
||||
let before = buf.filled().len();
|
||||
let polled = std::pin::Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let std::task::Poll::Ready(Ok(())) = &polled {
|
||||
self.done += (buf.filled().len() - before) as u64;
|
||||
if self.done - self.said >= 128 * 1024 {
|
||||
self.said = self.done;
|
||||
(self.report)(self.base + self.done, self.total);
|
||||
}
|
||||
}
|
||||
polled
|
||||
}
|
||||
}
|
||||
|
||||
/// What a file is, for choosing how Telegram is told about it.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Sort {
|
||||
Photo,
|
||||
Video,
|
||||
Other,
|
||||
}
|
||||
|
||||
pub fn sort_of(name: &str) -> Sort {
|
||||
match name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg" | "jpeg" | "png" | "webp" | "bmp") => Sort::Photo,
|
||||
Some("mp4" | "mov" | "webm" | "mkv" | "m4v") => Sort::Video,
|
||||
_ => Sort::Other,
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload(client: &grammers_client::Client, path: &str, base: u64, total: u64, report: &(dyn Fn(u64, u64) + Send + Sync)) -> Result<(Uploaded, String), String> {
|
||||
let file = tokio::fs::File::open(path).await.map_err(|e| format!("could not read {path}: {e}"))?;
|
||||
let size = file.metadata().await.map_err(|e| e.to_string())?.len();
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".to_string());
|
||||
let mut reader = Counting { inner: file, done: 0, said: 0, base, total, report };
|
||||
let up = client.upload_stream(&mut reader, size as usize, name.clone()).await.map_err(|e| format!("Telegram would not take {name}: {e}"))?;
|
||||
Ok((up, name))
|
||||
}
|
||||
|
||||
/// A picture or a video as media. Anything else is sent as a plain file (see `send_files`).
|
||||
fn media_for(up: Uploaded, sort: Sort, spoiler: bool) -> Option<tl::enums::InputMedia> {
|
||||
match sort {
|
||||
Sort::Photo => Some(tl::types::InputMediaUploadedPhoto { spoiler, file: up.raw, stickers: None, ttl_seconds: None, live_photo: false, video: None }.into()),
|
||||
Sort::Video => Some(grammers_client::media::InputVideo::new(up).supports_streaming(true).spoiler(spoiler).into()),
|
||||
Sort::Other => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends files, with the caption on the first: one message each, or an album where there are several pictures and
|
||||
/// videos. `progress` names the ring the window draws.
|
||||
pub async fn send_files(state: &AppState, buffer: &Buffer, caption: &str, files: &[(String, bool)], reply_to: Option<&str>, progress: Option<&Progress>) -> Result<(), String> {
|
||||
if files.is_empty() {
|
||||
return send(state, buffer, caption, reply_to).await;
|
||||
}
|
||||
let (text, entities) = if caption.trim().is_empty() { (String::new(), Vec::new()) } else { checked(caption)? };
|
||||
// Telegram's limit on a caption is much smaller than on a message.
|
||||
if units(&text) > 1024 {
|
||||
return Err(format!("A caption can be at most 1024 characters, and this has {}", units(&text)));
|
||||
}
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let reply = reply_to.and_then(|id| id.parse::<i32>().ok());
|
||||
let total: u64 = files.iter().filter_map(|(p, _)| std::fs::metadata(p).ok().map(|m| m.len())).sum();
|
||||
if let Some(p) = progress {
|
||||
p.at(Phase::Preparing, total as usize, "Telegram");
|
||||
}
|
||||
let report = |sent: u64, all: u64| {
|
||||
if let Some(p) = progress {
|
||||
p.sent(sent, all, "Telegram");
|
||||
}
|
||||
};
|
||||
let mut uploaded: Vec<(Uploaded, Sort, bool)> = Vec::new();
|
||||
let mut base = 0u64;
|
||||
for (path, spoiler) in files {
|
||||
let (up, name) = upload(&handle.client, path, base, total, &report).await?;
|
||||
base += std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
uploaded.push((up, sort_of(&name), *spoiler));
|
||||
}
|
||||
if let Some(p) = progress {
|
||||
p.at(Phase::Waiting, total as usize, "Telegram");
|
||||
}
|
||||
let album = uploaded.len() >= 2 && uploaded.len() <= 10 && uploaded.iter().all(|(_, sort, _)| *sort != Sort::Other);
|
||||
if album {
|
||||
let medias: Vec<InputMedia> = uploaded
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (up, sort, spoiler))| {
|
||||
let mut m = InputMedia::new().media(media_for(up, sort, spoiler).expect("an album is only pictures and videos"));
|
||||
if i == 0 {
|
||||
m = m.caption(text.clone()).fmt_entities(entities.clone()).reply_to(reply);
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
for sent in handle.client.send_album(peer, medias).await.map_err(|e| explain(&e))?.into_iter().flatten() {
|
||||
messages::record(state, &buffer.account_id, &handle.own, &sent);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
for (i, (up, sort, spoiler)) in uploaded.into_iter().enumerate() {
|
||||
let mut input = match media_for(up.clone(), sort, spoiler) {
|
||||
Some(media) => InputMessage::new().media(media),
|
||||
None => InputMessage::new().file(up),
|
||||
}
|
||||
.reply_to(reply);
|
||||
if i == 0 {
|
||||
input = input.text(text.clone()).fmt_entities(entities.clone());
|
||||
}
|
||||
let sent = handle.client.send_message(peer, input).await.map_err(|e| explain(&e))?;
|
||||
messages::record(state, &buffer.account_id, &handle.own, &sent);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a recording as a voice message (an Ogg file of Opus, which is what the recorder writes).
|
||||
pub async fn send_voice(state: &AppState, buffer: &Buffer, path: &std::path::Path, duration_secs: f64) -> Result<(), String> {
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let none = |_: u64, _: u64| {};
|
||||
let (up, _) = upload(&handle.client, &path.to_string_lossy(), 0, 0, &none).await?;
|
||||
let voice = grammers_client::media::InputAudio::new(up).voice(true).duration(std::time::Duration::from_secs_f64(duration_secs.max(0.0))).mime_type("audio/ogg");
|
||||
let sent = handle.client.send_message(peer, InputMessage::new().media(voice)).await.map_err(|e| explain(&e))?;
|
||||
messages::record(state, &buffer.account_id, &handle.own, &sent);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use grammers_mtsender::RpcError;
|
||||
|
||||
fn rpc(code: i32, name: &str, value: Option<u32>) -> InvocationError {
|
||||
InvocationError::Rpc(RpcError { code, name: name.to_string(), value, caused_by: None })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegrams_refusals_are_said_in_words_with_their_numbers() {
|
||||
assert_eq!(explain(&rpc(420, "FLOOD_WAIT", Some(30))), "Telegram says to wait 30 seconds before doing that again");
|
||||
assert_eq!(explain(&rpc(420, "SLOWMODE_WAIT", Some(10))), "Slow mode is on here: wait 10 seconds between messages");
|
||||
assert_eq!(explain(&rpc(403, "CHAT_WRITE_FORBIDDEN", None)), peers::NO_SEND_PERMISSION);
|
||||
assert!(explain(&rpc(400, "MESSAGE_TOO_LONG", None)).contains("4096"));
|
||||
assert_eq!(explain(&rpc(400, "SOMETHING_NEW_HAPPENED", None)), "Telegram refused: something new happened");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_is_sent_as_a_picture_a_video_or_a_file_by_its_extension() {
|
||||
assert_eq!(sort_of("Holiday.JPG"), Sort::Photo);
|
||||
assert_eq!(sort_of("clip.mp4"), Sort::Video);
|
||||
assert_eq!(sort_of("report.pdf"), Sort::Other);
|
||||
assert_eq!(sort_of("noextension"), Sort::Other);
|
||||
// A GIF is sent as a file so Telegram keeps it moving.
|
||||
assert_eq!(sort_of("cat.gif"), Sort::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cannot_be_sent_is_stopped_before_anything_is_sent() {
|
||||
assert!(checked(" ").unwrap_err().contains("nothing to send"));
|
||||
let long = "x".repeat(MAX_UNITS + 1);
|
||||
assert!(checked(&long).unwrap_err().contains("too long"));
|
||||
assert!(checked(&"x".repeat(MAX_UNITS)).is_ok());
|
||||
// An emoji is two units, and Telegram counts units.
|
||||
assert!(checked(&"😀".repeat(MAX_UNITS / 2 + 1)).unwrap_err().contains("too long"));
|
||||
// Formatting marks do not count against the limit: they are taken out.
|
||||
let (text, entities) = checked("**hello**").unwrap();
|
||||
assert_eq!((text.as_str(), entities.len()), ("hello", 1));
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
//! What grammers remembers between runs: the authorization key Telegram issued, the datacenters it found, the people
|
||||
//! and chats it has met (their access hashes, without which a chat cannot be addressed again) and how far the
|
||||
//! account's updates have been read.
|
||||
//!
|
||||
//! The library offers a SQLite store, which would bring a second SQLite beside the two this daemon already links,
|
||||
//! and a memory store that cannot be saved. This is its own: the same bookkeeping as the memory store, kept in a file
|
||||
//! of its own in the daemon's data directory (one per account, private, written whole and renamed into place). The file is
|
||||
//! a credential: the authorization key in it is the account.
|
||||
//!
|
||||
//! It is written when something changed and a few seconds have passed (`save_if_dirty`, called from the connection's
|
||||
//! loop), and once more when the connection ends or a sign-in finishes.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use grammers_session::types::{ChannelState, DcOption, PeerId, PeerInfo, UpdateState, UpdatesState};
|
||||
use grammers_session::{BoxFuture, Session, SessionData};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Where an account's session lives.
|
||||
pub fn path_for(user_id: i64) -> PathBuf {
|
||||
folder().join(format!("{user_id}.session"))
|
||||
}
|
||||
|
||||
/// A sign-in that has not got as far as having an account yet keeps its session here until it has.
|
||||
pub fn path_for_login(login_id: &str) -> PathBuf {
|
||||
let safe: String = login_id.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').collect();
|
||||
folder().join(format!("login-{safe}.session"))
|
||||
}
|
||||
|
||||
fn folder() -> PathBuf {
|
||||
crate::default_data_dir().join("telegram")
|
||||
}
|
||||
|
||||
/// The file's form: what the library's own type holds, with the maps as lists (a JSON object cannot be keyed by a
|
||||
/// peer).
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct Saved {
|
||||
home_dc: i32,
|
||||
dc_options: Vec<DcOption>,
|
||||
peers: Vec<PeerInfo>,
|
||||
updates: UpdatesState,
|
||||
}
|
||||
|
||||
pub struct FileSession {
|
||||
path: Mutex<PathBuf>,
|
||||
data: Mutex<SessionData>,
|
||||
dirty: AtomicBool,
|
||||
}
|
||||
|
||||
impl FileSession {
|
||||
/// A new session, with Telegram's datacenters as the library knows them and no key yet.
|
||||
pub fn fresh(path: PathBuf) -> Self {
|
||||
Self { path: Mutex::new(path), data: Mutex::new(SessionData::default()), dirty: AtomicBool::new(false) }
|
||||
}
|
||||
|
||||
/// The session a file holds, or a fresh one if there is no file.
|
||||
pub fn open(path: PathBuf) -> Result<Self> {
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::fresh(path)),
|
||||
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
|
||||
};
|
||||
let saved: Saved = serde_json::from_str(&text).with_context(|| format!("reading the session in {}", path.display()))?;
|
||||
let mut data = SessionData::default();
|
||||
if saved.home_dc != 0 {
|
||||
data.home_dc = saved.home_dc;
|
||||
}
|
||||
for option in saved.dc_options {
|
||||
data.dc_options.insert(option.id, option);
|
||||
}
|
||||
data.peer_infos = saved.peers.into_iter().map(|p| (p.id(), p)).collect();
|
||||
data.updates_state = saved.updates;
|
||||
Ok(Self { path: Mutex::new(path), data: Mutex::new(data), dirty: AtomicBool::new(false) })
|
||||
}
|
||||
|
||||
fn data(&self) -> MutexGuard<'_, SessionData> {
|
||||
// Nothing here can leave the data half-changed, so a lock a panic left poisoned is still good.
|
||||
self.data.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Whether the key Telegram issued for the home datacenter is there: what a signed-in session has.
|
||||
pub fn has_key(&self) -> bool {
|
||||
let data = self.data();
|
||||
data.dc_options.get(&data.home_dc).is_some_and(|o| o.auth_key.is_some())
|
||||
}
|
||||
|
||||
/// Moves the file, when a sign-in learns whose session this is.
|
||||
pub fn move_to(&self, path: PathBuf) -> Result<()> {
|
||||
let old = std::mem::replace(&mut *self.path.lock().unwrap_or_else(|e| e.into_inner()), path);
|
||||
self.save()?;
|
||||
if old != *self.path.lock().unwrap_or_else(|e| e.into_inner()) {
|
||||
let _ = std::fs::remove_file(old);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes the session, whole and private, and renames it over the last.
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = self.path.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let saved = {
|
||||
let data = self.data();
|
||||
Saved { home_dc: data.home_dc, dc_options: data.dc_options.values().cloned().collect(), peers: data.peer_infos.values().cloned().collect(), updates: data.updates_state.clone() }
|
||||
};
|
||||
write_private(&path, serde_json::to_string(&saved)?.as_bytes())?;
|
||||
self.dirty.store(false, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes it if anything changed since the last time.
|
||||
pub fn save_if_dirty(&self) -> Result<()> {
|
||||
if self.dirty.load(Ordering::Relaxed) {
|
||||
self.save()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Takes the file away: an account that was removed, or a sign-in that was given up.
|
||||
pub fn delete(&self) {
|
||||
let path = self.path.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let _ = std::fs::remove_file(path);
|
||||
self.dirty.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn touch(&self) {
|
||||
self.dirty.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("making {}", parent.display()))?;
|
||||
}
|
||||
let tmp = path.with_extension("session.tmp");
|
||||
{
|
||||
// Private from the moment it exists, as the accounts file is.
|
||||
let mut file = crate::secure::create_private_file(&tmp)?;
|
||||
file.write_all(bytes)?;
|
||||
}
|
||||
std::fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
|
||||
impl Session for FileSession {
|
||||
type Error = Infallible;
|
||||
|
||||
fn home_dc_id(&self) -> Result<i32, Infallible> {
|
||||
Ok(self.data().home_dc)
|
||||
}
|
||||
|
||||
fn set_home_dc_id(&self, dc_id: i32) -> BoxFuture<'_, Result<(), Infallible>> {
|
||||
Box::pin(async move {
|
||||
self.data().home_dc = dc_id;
|
||||
self.touch();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn dc_option(&self, dc_id: i32) -> Result<Option<DcOption>, Infallible> {
|
||||
Ok(self.data().dc_options.get(&dc_id).cloned())
|
||||
}
|
||||
|
||||
fn set_dc_option(&self, dc_option: &DcOption) -> BoxFuture<'_, Result<(), Infallible>> {
|
||||
let dc_option = dc_option.clone();
|
||||
Box::pin(async move {
|
||||
self.data().dc_options.insert(dc_option.id, dc_option);
|
||||
self.touch();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn peer(&self, peer: PeerId) -> BoxFuture<'_, Result<Option<PeerInfo>, Infallible>> {
|
||||
Box::pin(async move { Ok(self.data().peer_infos.get(&peer).cloned()) })
|
||||
}
|
||||
|
||||
fn cache_peer(&self, peer: PeerInfo) -> BoxFuture<'_, Result<(), Infallible>> {
|
||||
Box::pin(async move {
|
||||
let changed = {
|
||||
let mut data = self.data();
|
||||
let known = data.peer_infos.entry(peer.id()).or_insert_with(|| peer.clone());
|
||||
known.extend_info(&peer)
|
||||
};
|
||||
if changed {
|
||||
self.touch();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn updates_state(&self) -> BoxFuture<'_, Result<UpdatesState, Infallible>> {
|
||||
Box::pin(async move { Ok(self.data().updates_state.clone()) })
|
||||
}
|
||||
|
||||
fn set_update_state(&self, update: UpdateState) -> BoxFuture<'_, Result<(), Infallible>> {
|
||||
Box::pin(async move {
|
||||
{
|
||||
let mut data = self.data();
|
||||
match update {
|
||||
UpdateState::All(state) => data.updates_state = state,
|
||||
UpdateState::Primary { pts, date, seq } => {
|
||||
data.updates_state.pts = pts;
|
||||
data.updates_state.date = date;
|
||||
data.updates_state.seq = seq;
|
||||
}
|
||||
UpdateState::Secondary { qts } => data.updates_state.qts = qts,
|
||||
UpdateState::Channel { id, pts } => {
|
||||
data.updates_state.channels.retain(|c| c.id != id);
|
||||
data.updates_state.channels.push(ChannelState { id, pts });
|
||||
}
|
||||
}
|
||||
}
|
||||
self.touch();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("nobilis-tg-session-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_is_written_private_and_comes_back_whole() {
|
||||
let dir = dir("roundtrip");
|
||||
let path = dir.join("1.session");
|
||||
let session = FileSession::fresh(path.clone());
|
||||
assert!(!session.has_key());
|
||||
let mut option = session.dc_option(2).unwrap().unwrap();
|
||||
option.auth_key = Some([9u8; 256]);
|
||||
session.set_dc_option(&option).await.unwrap();
|
||||
session.set_home_dc_id(2).await.unwrap();
|
||||
session.set_update_state(UpdateState::Primary { pts: 10, date: 20, seq: 30 }).await.unwrap();
|
||||
session.set_update_state(UpdateState::Channel { id: 5, pts: 7 }).await.unwrap();
|
||||
assert!(session.has_key());
|
||||
session.save_if_dirty().unwrap();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600, "the key in it is the account");
|
||||
}
|
||||
let back = FileSession::open(path.clone()).unwrap();
|
||||
assert!(back.has_key());
|
||||
assert_eq!(back.dc_option(2).unwrap().unwrap().auth_key, Some([9u8; 256]));
|
||||
let state = back.updates_state().await.unwrap();
|
||||
assert_eq!((state.pts, state.date, state.seq), (10, 20, 30));
|
||||
assert_eq!(state.channels.iter().map(|c| (c.id, c.pts)).collect::<Vec<_>>(), vec![(5, 7)]);
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_is_written_until_something_changed_and_a_missing_file_is_a_fresh_session() {
|
||||
let dir = dir("clean");
|
||||
let path = dir.join("2.session");
|
||||
let session = FileSession::open(path.clone()).unwrap();
|
||||
session.save_if_dirty().unwrap();
|
||||
assert!(!path.exists(), "an unchanged session writes nothing");
|
||||
session.set_home_dc_id(4).await.unwrap();
|
||||
session.save_if_dirty().unwrap();
|
||||
assert!(path.exists());
|
||||
let moved = dir.join("3.session");
|
||||
session.move_to(moved.clone()).unwrap();
|
||||
assert!(moved.exists() && !path.exists(), "a sign-in's file moves to the account's name");
|
||||
session.delete();
|
||||
assert!(!moved.exists());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
//! Replies in a thread, and the comments under a channel's post.
|
||||
//!
|
||||
//! A reply chain in a group is a thread the window already knows how to draw from the messages it has (the replies
|
||||
//! name the message at the top, see `messages::record`). What it cannot have is what was said before this client read
|
||||
//! it, and a channel's comments, which are messages in the discussion group linked to the channel: those are asked for
|
||||
//! here when a thread is opened, and a comment is sent to that group as an answer to the post's copy in it.
|
||||
|
||||
use super::peers;
|
||||
use super::messages;
|
||||
use super::send::{explain, input_for, peer_for};
|
||||
use crate::model::Buffer;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::{PeerAuth, PeerId, PeerRef};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// How many replies a thread is read to.
|
||||
const LIMIT: i32 = 100;
|
||||
|
||||
fn user_names(users: &[tl::enums::User]) -> HashMap<i64, String> {
|
||||
users
|
||||
.iter()
|
||||
.filter_map(|u| match u {
|
||||
tl::enums::User::User(u) => {
|
||||
let name = [u.first_name.clone().unwrap_or_default(), u.last_name.clone().unwrap_or_default()].iter().filter(|s| !s.is_empty()).cloned().collect::<Vec<_>>().join(" ");
|
||||
Some((u.id, if name.is_empty() { u.username.clone().unwrap_or_else(|| "Deleted account".to_string()) } else { name }))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One of Telegram's own messages as the window reads a message.
|
||||
pub fn message_json(message: &tl::enums::Message, names: &HashMap<i64, String>, chat_name: &str, buffer_id: &str, own_name: &str) -> Option<Value> {
|
||||
let tl::enums::Message::Message(m) = message else { return None };
|
||||
let from = if m.out {
|
||||
own_name.to_string()
|
||||
} else {
|
||||
match &m.from_id {
|
||||
Some(tl::enums::Peer::User(u)) => names.get(&u.user_id).cloned().unwrap_or_else(|| "Somebody".to_string()),
|
||||
_ => chat_name.to_string(),
|
||||
}
|
||||
};
|
||||
let mut body = m.message.clone();
|
||||
if let Some(label) = m.media.as_ref().and_then(super::messages::media_label) {
|
||||
if !body.is_empty() {
|
||||
body.push('\n');
|
||||
}
|
||||
body.push_str(&label);
|
||||
}
|
||||
Some(json!({
|
||||
"id": m.id.to_string(),
|
||||
"bufferId": buffer_id,
|
||||
"from": from,
|
||||
"body": body,
|
||||
"ts": m.date,
|
||||
"kind": "chat",
|
||||
"isAction": false,
|
||||
"isHighlight": false,
|
||||
"edited": m.edit_date.is_some(),
|
||||
"isOwn": m.out,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A thread's replies, oldest first.
|
||||
pub async fn fetch(state: &AppState, buffer: &Buffer, root_id: &str) -> Result<Vec<Value>, String> {
|
||||
let root: i32 = root_id.parse().map_err(|_| "that message has no thread".to_string())?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let answer = handle
|
||||
.client
|
||||
.invoke(&tl::functions::messages::GetReplies { peer: peer.into(), msg_id: root, offset_id: 0, offset_date: 0, add_offset: 0, limit: LIMIT, max_id: 0, min_id: 0, hash: 0 })
|
||||
.await
|
||||
.map_err(|e| explain(&e))?;
|
||||
let (messages, users, chats) = match answer {
|
||||
tl::enums::messages::Messages::Messages(m) => (m.messages, m.users, m.chats),
|
||||
tl::enums::messages::Messages::Slice(m) => (m.messages, m.users, m.chats),
|
||||
tl::enums::messages::Messages::ChannelMessages(m) => (m.messages, m.users, m.chats),
|
||||
tl::enums::messages::Messages::NotModified(_) => (Vec::new(), Vec::new(), Vec::new()),
|
||||
};
|
||||
let names = user_names(&users);
|
||||
let mut out: Vec<Value> = messages
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
// A comment is in the discussion group, not the channel: filed under that group's buffer when it is one.
|
||||
let discussion = match m {
|
||||
tl::enums::Message::Message(raw) => match &raw.peer_id {
|
||||
tl::enums::Peer::Channel(c) if buffer.remote_id.as_deref() != Some(&peers::remote_id(PeerId::channel_unchecked(c.channel_id))) => {
|
||||
state.runtime.list_buffers().into_iter().find(|b| b.account_id == buffer.account_id && b.remote_id.as_deref() == Some(&peers::remote_id(PeerId::channel_unchecked(c.channel_id)))).map(|b| b.id)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let chat = chats.first().map(|c| match c {
|
||||
tl::enums::Chat::Channel(c) => c.title.clone(),
|
||||
tl::enums::Chat::Chat(c) => c.title.clone(),
|
||||
_ => buffer.name.clone(),
|
||||
});
|
||||
message_json(m, &names, chat.as_deref().unwrap_or(&buffer.name), discussion.as_deref().unwrap_or(&buffer.id), &handle.own.name)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|m| m["ts"].as_i64().unwrap_or(0));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Says something in a thread: under a channel's post it goes to the discussion group, as an answer to the post's copy
|
||||
/// there; anywhere else it is an answer to the message the thread grew from.
|
||||
pub async fn reply(state: &AppState, buffer: &Buffer, root_id: &str, body: &str) -> Result<(), String> {
|
||||
let post: i32 = root_id.parse().map_err(|_| "that message has no thread".to_string())?;
|
||||
let input = input_for(body)?;
|
||||
let (handle, peer) = peer_for(state, buffer).await?;
|
||||
let found = match handle.client.invoke(&tl::functions::messages::GetDiscussionMessage { peer: peer.into(), msg_id: post }).await {
|
||||
Ok(tl::enums::messages::DiscussionMessage::Message(found)) => Some(found),
|
||||
// A message nobody has answered yet has no thread to be found in: it is answered like any other.
|
||||
Err(_) => None,
|
||||
};
|
||||
let target = found.as_ref().and_then(|found| {
|
||||
let Some(tl::enums::Message::Message(copy)) = found.messages.first() else { return None };
|
||||
let tl::enums::Peer::Channel(group) = ©.peer_id else { return None };
|
||||
let hash = found.chats.iter().find_map(|c| match c {
|
||||
tl::enums::Chat::Channel(c) if c.id == group.channel_id => c.access_hash,
|
||||
_ => None,
|
||||
})?;
|
||||
Some((PeerRef { id: PeerId::channel_unchecked(group.channel_id), auth: PeerAuth::from_hash(hash) }, copy.id))
|
||||
});
|
||||
let (to, reply_to) = target.unwrap_or((peer, post));
|
||||
let sent = handle.client.send_message(to, input.reply_to(Some(reply_to))).await.map_err(|e| explain(&e))?;
|
||||
messages::record(state, &buffer.account_id, &handle.own, &sent);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw(id: i32, out: bool, from: i64, text: &str) -> tl::enums::Message {
|
||||
tl::enums::Message::Message(tl::types::Message {
|
||||
out,
|
||||
mentioned: false,
|
||||
media_unread: false,
|
||||
silent: false,
|
||||
post: false,
|
||||
from_scheduled: false,
|
||||
legacy: false,
|
||||
edit_hide: false,
|
||||
pinned: false,
|
||||
noforwards: false,
|
||||
invert_media: false,
|
||||
offline: false,
|
||||
video_processing_pending: false,
|
||||
paid_suggested_post_stars: false,
|
||||
paid_suggested_post_ton: false,
|
||||
id,
|
||||
from_id: Some(tl::enums::Peer::User(tl::types::PeerUser { user_id: from })),
|
||||
from_boosts_applied: None,
|
||||
from_rank: None,
|
||||
peer_id: tl::enums::Peer::User(tl::types::PeerUser { user_id: from }),
|
||||
saved_peer_id: None,
|
||||
fwd_from: None,
|
||||
via_bot_id: None,
|
||||
via_business_bot_id: None,
|
||||
reply_to: None,
|
||||
date: 1790000000 + id,
|
||||
message: text.into(),
|
||||
media: None,
|
||||
reply_markup: None,
|
||||
entities: None,
|
||||
views: None,
|
||||
forwards: None,
|
||||
replies: None,
|
||||
edit_date: None,
|
||||
post_author: None,
|
||||
grouped_id: None,
|
||||
reactions: None,
|
||||
restriction_reason: None,
|
||||
ttl_period: None,
|
||||
quick_reply_shortcut_id: None,
|
||||
effect: None,
|
||||
factcheck: None,
|
||||
report_delivery_until_date: None,
|
||||
paid_message_stars: None,
|
||||
suggested_post: None,
|
||||
schedule_repeat_period: None,
|
||||
summary_from_language: None,
|
||||
guestchat_via_from: None,
|
||||
rich_message: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_read_as_a_message_with_its_authors_name() {
|
||||
let names: HashMap<i64, String> = [(7, "Ann".to_string())].into_iter().collect();
|
||||
let got = message_json(&raw(5, false, 7, "hello"), &names, "Chat", "telegram:1|Chat", "Me").unwrap();
|
||||
assert_eq!((got["id"].as_str(), got["from"].as_str(), got["body"].as_str(), got["isOwn"].as_bool()), (Some("5"), Some("Ann"), Some("hello"), Some(false)));
|
||||
let mine = message_json(&raw(6, true, 1, "mine"), &names, "Chat", "telegram:1|Chat", "Me").unwrap();
|
||||
assert_eq!((mine["from"].as_str(), mine["isOwn"].as_bool()), (Some("Me"), Some(true)));
|
||||
assert!(message_json(&tl::enums::Message::Empty(tl::types::MessageEmpty { id: 1, peer_id: None }), &names, "c", "b", "m").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! What Telegram sends that is not a message: somebody typing, a conversation read on another of the account's apps.
|
||||
//!
|
||||
//! The library turns messages, edits and deletions into updates of their own; everything else arrives as the raw
|
||||
//! update Telegram sent, which is read here.
|
||||
|
||||
use super::peers;
|
||||
use super::Handle;
|
||||
use crate::state::AppState;
|
||||
use grammers_client::tl;
|
||||
use grammers_session::types::PeerId;
|
||||
|
||||
/// How long somebody's "typing" lasts without another: Telegram sends one every few seconds while they type.
|
||||
const TYPING_FOR_MS: u64 = 6000;
|
||||
|
||||
/// The buffer for a conversation named by Telegram's id for it, if there is one.
|
||||
fn buffer_of(state: &AppState, account_id: &str, remote: &str) -> Option<crate::model::Buffer> {
|
||||
state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && b.remote_id.as_deref() == Some(remote))
|
||||
}
|
||||
|
||||
fn is_typing(action: &tl::enums::SendMessageAction) -> bool {
|
||||
use tl::enums::SendMessageAction as A;
|
||||
!matches!(action, A::SendMessageCancelAction | A::SpeakingInGroupCallAction)
|
||||
}
|
||||
|
||||
fn who(handle: &Handle, user: i64) -> Option<String> {
|
||||
if user == handle.own.user_id {
|
||||
return None;
|
||||
}
|
||||
handle.names.lock().unwrap_or_else(|e| e.into_inner()).get(&user).cloned()
|
||||
}
|
||||
|
||||
fn say_typing(state: &AppState, account_id: &str, buffer: &crate::model::Buffer, name: String) {
|
||||
state.events.emit("typing", serde_json::json!({ "accountId": account_id, "bufferId": buffer.id, "nicks": [name], "expiresInMs": TYPING_FOR_MS }));
|
||||
}
|
||||
|
||||
/// Marks a conversation read here when Telegram says it was read up to its newest message elsewhere.
|
||||
fn read_up_to(state: &AppState, account_id: &str, remote: &str, max_id: i32) {
|
||||
let Some(buffer) = buffer_of(state, account_id, remote) else { return };
|
||||
let newest = state.store.newest_message_id(&buffer.id).ok().flatten().and_then(|id| id.parse::<i32>().ok()).unwrap_or(0);
|
||||
if max_id >= newest {
|
||||
state.events.emit("bufferRead", serde_json::json!({ "accountId": account_id, "bufferId": buffer.id }));
|
||||
}
|
||||
}
|
||||
|
||||
/// The pins of a conversation changed: read them again, which says so to every window.
|
||||
fn pins_changed(state: &AppState, account_id: &str, _handle: &Handle, remote: &str) {
|
||||
let Some(buffer) = buffer_of(state, account_id, remote) else { return };
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = super::pins::list(&state, &buffer).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles one raw update.
|
||||
pub fn raw(state: &AppState, account_id: &str, handle: &Handle, update: &tl::enums::Update) {
|
||||
use tl::enums::Update as U;
|
||||
match update {
|
||||
U::UserTyping(u) if is_typing(&u.action) => {
|
||||
if let (Some(buffer), Some(name)) = (buffer_of(state, account_id, &u.user_id.to_string()), who(handle, u.user_id)) {
|
||||
say_typing(state, account_id, &buffer, name);
|
||||
}
|
||||
}
|
||||
U::ChatUserTyping(u) if is_typing(&u.action) => {
|
||||
let user = PeerId::from(u.from_id.clone()).bare_id();
|
||||
if let (Some(buffer), Some(name)) = (buffer_of(state, account_id, &peers::remote_id(PeerId::chat_unchecked(u.chat_id))), user.and_then(|id| who(handle, id))) {
|
||||
say_typing(state, account_id, &buffer, name);
|
||||
}
|
||||
}
|
||||
U::ChannelUserTyping(u) if is_typing(&u.action) => {
|
||||
let user = PeerId::from(u.from_id.clone()).bare_id();
|
||||
if let (Some(buffer), Some(name)) = (buffer_of(state, account_id, &peers::remote_id(PeerId::channel_unchecked(u.channel_id))), user.and_then(|id| who(handle, id))) {
|
||||
say_typing(state, account_id, &buffer, name);
|
||||
}
|
||||
}
|
||||
U::MessageReactions(u) => super::reactions::update(state, account_id, &peers::remote_id(PeerId::from(u.peer.clone())), u.msg_id, &u.reactions),
|
||||
U::MessagePoll(u) => super::polls::update(state, u),
|
||||
U::NotifySettings(u) => super::notify::changed(state, account_id, u),
|
||||
U::PinnedMessages(u) => pins_changed(state, account_id, handle, &peers::remote_id(PeerId::from(u.peer.clone()))),
|
||||
U::PinnedChannelMessages(u) => pins_changed(state, account_id, handle, &peers::remote_id(PeerId::channel_unchecked(u.channel_id))),
|
||||
U::ReadHistoryInbox(u) => read_up_to(state, account_id, &peers::remote_id(PeerId::from(u.peer.clone())), u.max_id),
|
||||
U::ReadChannelInbox(u) => read_up_to(state, account_id, &peers::remote_id(PeerId::channel_unchecked(u.channel_id)), u.max_id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,6 @@ pub fn reply_of(line: &Line, text: &str) -> (Option<ReplyPreview>, String) {
|
||||
thread: false,
|
||||
forwarded: false,
|
||||
thread_root: Some(root.to_string()),
|
||||
chat: None,
|
||||
}),
|
||||
body,
|
||||
)
|
||||
|
||||
@@ -813,7 +813,7 @@ where
|
||||
let reply = match (&parent, reply_to) {
|
||||
(Some(p), _) => {
|
||||
let root = p.reply_to.as_ref().and_then(|r| r.thread_root.clone()).unwrap_or_else(|| p.id.clone());
|
||||
Some(crate::model::ReplyPreview { id: p.id.clone(), from: p.from.clone(), body: p.body.clone(), thread: false, forwarded: false, thread_root: Some(root), chat: None })
|
||||
Some(crate::model::ReplyPreview { id: p.id.clone(), from: p.from.clone(), body: p.body.clone(), thread: false, forwarded: false, thread_root: Some(root) })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -343,9 +343,6 @@ async fn run() -> Result<()> {
|
||||
for cfg in state.accounts.all_twitch() {
|
||||
backend::twitch::spawn(state.clone(), cfg);
|
||||
}
|
||||
for cfg in state.accounts.all_telegram() {
|
||||
backend::telegram::connect::spawn(state.clone(), cfg);
|
||||
}
|
||||
|
||||
// What was going on last time, before anything is served: a window that
|
||||
// connects immediately should see the same list it was looking at.
|
||||
|
||||
@@ -38,8 +38,6 @@ pub const CACHES: &[Cache] = &[
|
||||
Cache { dir: "matrix-media", group: "media", max_bytes: 250 * MB },
|
||||
Cache { dir: "sneedchat-attachments", group: "media", max_bytes: 250 * MB },
|
||||
Cache { dir: "discord-thumbnails", group: "media", max_bytes: 100 * MB },
|
||||
Cache { dir: "telegram-media", group: "media", max_bytes: 500 * MB },
|
||||
Cache { dir: "telegram-avatars", group: "avatars", max_bytes: 64 * MB },
|
||||
Cache { dir: "sneedchat-avatars", group: "avatars", max_bytes: 100 * MB },
|
||||
Cache { dir: "discord-icons", group: "avatars", max_bytes: 16 * MB },
|
||||
Cache { dir: "kick-emotes", group: "emotes", max_bytes: 64 * MB },
|
||||
@@ -211,13 +209,6 @@ pub async fn restore(state: &AppState, path: &str, buffer_id: Option<&str>, mess
|
||||
}
|
||||
"discord-icons" => crate::backend::discord::restore_guild_icon(&path).await?,
|
||||
"kick-emotes" => crate::backend::kick::emotecache::restore_cached(&path).await?,
|
||||
"telegram-media" | "telegram-avatars" => match crate::backend::telegram::media::restore_cached(state, &path).await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!("telegram: fetching {} again: {e:#}", path.display());
|
||||
return Err(e);
|
||||
}
|
||||
},
|
||||
_ => bail!("{} is fetched again by what uses it, not by name", cache.dir),
|
||||
};
|
||||
Ok(format!("file://{}", restored.display()))
|
||||
|
||||
@@ -379,10 +379,6 @@ pub struct Buffer {
|
||||
/// thing - so it is named for what it is rather than after the tag.
|
||||
#[serde(rename = "serviceRoom", default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub service_room: bool,
|
||||
/// A small glyph drawn in front of the name in the list, for what kind of conversation it is where the service
|
||||
/// has kinds a person looks for: Telegram's broadcast channels ("campaign", a horn) and bots ("smart_toy").
|
||||
#[serde(rename = "nameIcon", default, skip_serializing_if = "Option::is_none")]
|
||||
pub name_icon: Option<String>,
|
||||
/// What the conversation says it is for: an IRC channel's topic, a Matrix
|
||||
/// room's, a Discord channel's. Absent when there is none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -486,12 +482,6 @@ pub struct ReplyPreview {
|
||||
/// above it, the second for a thread to open.
|
||||
#[serde(rename = "threadRoot", default, skip_serializing_if = "Option::is_none")]
|
||||
pub thread_root: Option<String>,
|
||||
/// The conversation the message answered lives in, when it is not this one.
|
||||
///
|
||||
/// Telegram can answer a post in another channel; the quote then names where it came from, and a client can
|
||||
/// take the reader there. A buffer id, present only when that conversation is one the account has open.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub chat: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a message the service took down is gone, kept with the message.
|
||||
@@ -1042,7 +1032,6 @@ mod tests {
|
||||
favourite: false,
|
||||
low_priority: false,
|
||||
service_room: false,
|
||||
name_icon: None,
|
||||
topic: None,
|
||||
read_only: None,
|
||||
forum: false,
|
||||
|
||||
+1
-215
@@ -112,7 +112,6 @@ fn adding_service(method: &str) -> Option<&'static str> {
|
||||
"addKickAccount" => Some("kick"),
|
||||
"addSteamAccount" | "addSteamAccountQr" => Some("steam"),
|
||||
"addTwitchAccount" | "startTwitchLogin" => Some("twitch"),
|
||||
"addTelegramAccount" => Some("telegram"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -427,7 +426,6 @@ pub async fn dispatch(
|
||||
{ "id": "kick", "name": "Kick", "available": true },
|
||||
{ "id": "steam", "name": "Steam", "available": true },
|
||||
{ "id": "twitch", "name": "Twitch", "available": true },
|
||||
{ "id": "telegram", "name": "Telegram", "available": true },
|
||||
])),
|
||||
None,
|
||||
),
|
||||
@@ -492,14 +490,6 @@ pub async fn dispatch(
|
||||
tracing::debug!("steam history: {e}");
|
||||
}
|
||||
}
|
||||
// Telegram keeps every message too: opening a conversation asks for its recent end, scrolling to
|
||||
// the top for the part before. Best-effort, like Steam's.
|
||||
if buffer.account_id.starts_with("telegram:") {
|
||||
let fetched = if before > 0 { backend::telegram::history::older(state, &buffer, before).await } else { backend::telegram::history::open(state, &buffer).await };
|
||||
if let Err(e) = fetched {
|
||||
tracing::debug!("telegram history: {e:#}");
|
||||
}
|
||||
}
|
||||
// Kick pages back through its own history endpoint,
|
||||
// continuing from where the last page stopped rather than
|
||||
// by timestamp - the endpoint is cursor-based, and asking
|
||||
@@ -791,14 +781,8 @@ pub async fn dispatch(
|
||||
// hours and a refresh token good for thirty days. Best effort:
|
||||
// the account is removed either way.
|
||||
let twitch_tokens = state.accounts.get_twitch(id).filter(|a| a.is_signed_in()).map(|a| (a.access_token, a.refresh_token));
|
||||
// A Telegram session is ended at Telegram too, and its file taken away.
|
||||
let telegram_account = state.accounts.get_telegram(id);
|
||||
match state.accounts.remove(id) {
|
||||
Ok(true) => {
|
||||
if let Some(config) = telegram_account {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move { backend::telegram::connect::sign_out(&state, &config).await });
|
||||
}
|
||||
if let Some((access, refresh)) = twitch_tokens {
|
||||
tokio::spawn(async move {
|
||||
if let Ok(http) = backend::twitch::client() {
|
||||
@@ -1028,12 +1012,6 @@ pub async fn dispatch(
|
||||
// Twitch is the same shape: a channel is a streamer, naming one is
|
||||
// the whole of joining it, and the connection remembers it once
|
||||
// Twitch has said it is there.
|
||||
if id.starts_with("telegram:") {
|
||||
return match backend::telegram::membership::join(state, id, name).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
}
|
||||
if id.starts_with("twitch:") {
|
||||
let Some(channel) = backend::twitch::connect::normalise_login(name) else {
|
||||
return (None, Some(format!("\"{name}\" is not a Twitch channel")));
|
||||
@@ -1102,15 +1080,6 @@ pub async fn dispatch(
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
|
||||
// It lapses by itself, so only "typing" is said.
|
||||
if typing {
|
||||
if let Err(e) = backend::telegram::send::typing(state, &buffer).await {
|
||||
tracing::debug!("sendTyping: {e}");
|
||||
}
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
// IRC says it with a client-only tag on an empty TAGMSG,
|
||||
// and says nothing at all where the server has no
|
||||
// message-tags to carry one - see send_typing.
|
||||
@@ -1175,27 +1144,6 @@ pub async fn dispatch(
|
||||
// Muting a room for the account rather than for this window. Matrix
|
||||
// keeps it server-side, so a room quietened here is quiet on a phone
|
||||
// too - which is what somebody means by muting a room.
|
||||
// The article a Telegram link preview carries, as a document for a window of its own.
|
||||
"telegramInstantView" => {
|
||||
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("telegram:")) else {
|
||||
return (None, Some("telegramInstantView requires a Telegram \"bufferId\"".to_string()));
|
||||
};
|
||||
match backend::telegram::instant::article(state, &buffer, p_str(params, "messageId", "")).await {
|
||||
Ok(article) => (Some(serde_json::json!({ "title": article.title, "url": article.url, "html": article.html })), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
"setTelegramChatMuted" => {
|
||||
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("telegram:")) else {
|
||||
return (None, Some("setTelegramChatMuted requires a Telegram \"bufferId\"".to_string()));
|
||||
};
|
||||
match backend::telegram::notify::set_muted(state, &buffer, p_bool(params, "muted", true)).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
"setMatrixRoomMuted" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("setMatrixRoomMuted requires \"bufferId\"".to_string()));
|
||||
@@ -1284,7 +1232,6 @@ pub async fn dispatch(
|
||||
"kick" => backend::kick::profile(&state, &account_id, &buffer_id, &who).await,
|
||||
"sneedchat" => backend::sneedchat::profile(&state, &account_id, &buffer_id, &who),
|
||||
"steam" => backend::steam::profile::build(&state, &account_id, &buffer_id, &who, &user_id).await,
|
||||
"telegram" => backend::telegram::profile::build(&state, &account_id, &buffer_id, &who, &user_id).await,
|
||||
// A service with nothing to add still answers, so the
|
||||
// card stops waiting and shows the name.
|
||||
other => {
|
||||
@@ -1310,12 +1257,6 @@ pub async fn dispatch(
|
||||
Ok(messages) => messages,
|
||||
Err(e) => return (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
} else if account_id.starts_with("telegram:") {
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else { return (None, Some("no such conversation".to_string())) };
|
||||
return match backend::telegram::threads::fetch(state, &buffer, root_id).await {
|
||||
Ok(messages) => (Some(serde_json::json!({ "messages": messages })), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
} else {
|
||||
state.store.thread_messages(buffer_id, root_id).unwrap_or_default()
|
||||
};
|
||||
@@ -1366,13 +1307,6 @@ pub async fn dispatch(
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
// Telegram keeps a read position for each conversation too, and tells the account's other apps.
|
||||
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
|
||||
if let Err(e) = backend::telegram::send::mark_read(state, &buffer).await {
|
||||
tracing::debug!("telegram read position: {e}");
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
// Steam keeps a read position for each conversation, and tells
|
||||
// every other client of the account when it moves.
|
||||
Some(buffer) if buffer.account_id.starts_with("steam:") => {
|
||||
@@ -1422,8 +1356,7 @@ pub async fn dispatch(
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
} else {
|
||||
let answers: Vec<Value> = params.get("responses").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
match backend::discord::accept_member_verification(state, account_id, guild_id, &answers).await {
|
||||
match backend::discord::accept_member_verification(state, account_id, guild_id).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
@@ -1528,8 +1461,6 @@ pub async fn dispatch(
|
||||
&finished.waveform,
|
||||
)
|
||||
.await
|
||||
} else if buffer.account_id.starts_with("telegram:") {
|
||||
backend::telegram::send::send_voice(state, &buffer, &finished.path, finished.duration_secs).await.map_err(|e| anyhow::anyhow!(e))
|
||||
} else if let Some(cfg) = state.accounts.get_matrix(&buffer.account_id) {
|
||||
backend::matrix::send_voice_message(
|
||||
state,
|
||||
@@ -1889,15 +1820,6 @@ pub async fn dispatch(
|
||||
false
|
||||
}
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
|
||||
let (state, buffer) = (state.clone(), buffer);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = backend::telegram::members::refresh(&state, &buffer).await {
|
||||
tracing::debug!("telegram members: {e}");
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
_ => backend::discord::request_member_list(state, buffer_id),
|
||||
};
|
||||
(Some(serde_json::json!({ "requested": asked })), None)
|
||||
@@ -2378,8 +2300,6 @@ pub async fn dispatch(
|
||||
backend::discord::load_context(state, &buffer.account_id, buffer_id, message_id).await
|
||||
} else if buffer.account_id.starts_with("matrix:") {
|
||||
backend::matrix::load_context(state, &buffer.account_id, buffer_id, message_id).await
|
||||
} else if buffer.account_id.starts_with("telegram:") {
|
||||
backend::telegram::history::context(state, &buffer, message_id).await
|
||||
} else {
|
||||
Err(anyhow::anyhow!("this service cannot be asked for one message"))
|
||||
};
|
||||
@@ -2420,8 +2340,6 @@ pub async fn dispatch(
|
||||
backend::discord::load_newer(state, &buffer.account_id, buffer_id, message_id).await
|
||||
} else if buffer.account_id.starts_with("matrix:") {
|
||||
backend::matrix::load_newer(state, &buffer.account_id, buffer_id, message_id).await
|
||||
} else if buffer.account_id.starts_with("telegram:") {
|
||||
backend::telegram::history::newer(state, &buffer, message_id).await
|
||||
} else {
|
||||
Err(anyhow::anyhow!("this service cannot be read forward"))
|
||||
};
|
||||
@@ -2447,8 +2365,6 @@ pub async fn dispatch(
|
||||
backend::discord::list_pinned(state, &buffer.account_id, buffer_id).await
|
||||
} else if buffer.account_id.starts_with("matrix:") {
|
||||
backend::matrix::list_pinned(state, &buffer.account_id, buffer_id).await
|
||||
} else if buffer.account_id.starts_with("telegram:") {
|
||||
backend::telegram::pins::list(state, &buffer).await.map_err(|e| anyhow::anyhow!(e))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("this service has no pinned messages"))
|
||||
};
|
||||
@@ -2498,8 +2414,6 @@ pub async fn dispatch(
|
||||
backend::discord::set_pinned(state, &buffer.account_id, buffer_id, message_id, pinned).await
|
||||
} else if buffer.account_id.starts_with("matrix:") {
|
||||
backend::matrix::set_pinned(state, &buffer.account_id, buffer_id, message_id, pinned).await
|
||||
} else if buffer.account_id.starts_with("telegram:") {
|
||||
backend::telegram::pins::set(state, &buffer, message_id, pinned).await.map_err(|e| anyhow::anyhow!(e))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("this service has no pinned messages"))
|
||||
};
|
||||
@@ -2509,25 +2423,6 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// The same question asked of Telegram's own index, for what this client never read.
|
||||
"searchTelegramMessages" => {
|
||||
let (buffer_id, query) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "query")) {
|
||||
(Some(b), Some(q)) => (b, q),
|
||||
_ => return (None, Some("searchTelegramMessages requires \"bufferId\" and \"query\"".to_string())),
|
||||
};
|
||||
if query.trim().is_empty() {
|
||||
return (Some(serde_json::json!({ "results": [], "roomNames": {}, "count": 0 })), None);
|
||||
}
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id).filter(|b| b.account_id.starts_with("telegram:")) else {
|
||||
return (None, Some("that conversation is not on Telegram".to_string()));
|
||||
};
|
||||
let account_wide = p_str_opt(params, "scope") == Some("account");
|
||||
match backend::telegram::search::run(state, &buffer, query, account_wide, p_i64(params, "limit", 25) as usize).await {
|
||||
Ok(answer) => (Some(answer), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
// The same question asked of the homeserver rather than of this
|
||||
// window's own copy.
|
||||
//
|
||||
@@ -3327,10 +3222,6 @@ pub async fn dispatch(
|
||||
let _ = sender.send(backend::twitch::Command::Part(buffer.name.clone()));
|
||||
}
|
||||
let _ = state.accounts.mutate_twitch(&buffer.account_id, |a| a.channels.retain(|c| *c != buffer.name));
|
||||
} else if buffer.account_id.starts_with("telegram:") {
|
||||
if let Err(e) = backend::telegram::membership::leave(state, &buffer).await {
|
||||
return (None, Some(e));
|
||||
}
|
||||
} else if buffer.account_id.starts_with("matrix:") {
|
||||
if let Some(room_id) = state.runtime.get_matrix_room(buffer_id) {
|
||||
if let Err(e) = backend::matrix::leave_room(state, &buffer.account_id, &room_id).await {
|
||||
@@ -3523,30 +3414,6 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(buffer) if buffer.account_id.starts_with("telegram:") && params.get("thread").and_then(|v| v.as_bool()).unwrap_or(false) && attachment_path.is_none() && reply_to_id.is_some() => {
|
||||
match backend::telegram::threads::reply(state, &buffer, reply_to_id.unwrap_or_default(), body).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
|
||||
let paths: Vec<String> = match params.get("attachmentPaths").and_then(|v| v.as_array()) {
|
||||
Some(list) => list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
None => attachment_path.map(|p| vec![p.to_string()]).unwrap_or_default(),
|
||||
};
|
||||
let spoilers: Vec<&str> = params.get("spoilerPaths").and_then(|v| v.as_array()).map(|list| list.iter().filter_map(|v| v.as_str()).collect()).unwrap_or_default();
|
||||
let files: Vec<(String, bool)> = paths.iter().map(|p| (p.clone(), spoilers.contains(&p.as_str()))).collect();
|
||||
let sent = backend::telegram::send::send_files(state, &buffer, body, &files, reply_to_id, progress).await;
|
||||
if let Some(p) = progress {
|
||||
if !files.is_empty() {
|
||||
p.done(sent.as_ref().err().map(String::as_str));
|
||||
}
|
||||
}
|
||||
match sent {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("steam:") => {
|
||||
// Steam has no replies or edits for a direct message in
|
||||
// this client yet: what arrives that it cannot use is
|
||||
@@ -3743,10 +3610,6 @@ pub async fn dispatch(
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
},
|
||||
},
|
||||
Some(buffer) if buffer.account_id.starts_with("telegram:") => match backend::telegram::send::edit(state, &buffer, msg_id, body).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
},
|
||||
Some(buffer) if buffer.account_id.starts_with("sneedchat:") => match backend::sneedchat::edit_message(state, &buffer.account_id, &buffer.name, msg_id, body) {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
@@ -3787,10 +3650,6 @@ pub async fn dispatch(
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
},
|
||||
},
|
||||
Some(buffer) if buffer.account_id.starts_with("telegram:") => match backend::telegram::reactions::toggle(state, &buffer, msg_id, emoji, add).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
},
|
||||
Some(buffer) if buffer.account_id.starts_with("steam:") && backend::steam::chatgroups::is_room(&buffer) => match backend::steam::chatgroups::react(state, &buffer, msg_id, emoji, add).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
@@ -3817,14 +3676,6 @@ pub async fn dispatch(
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
},
|
||||
},
|
||||
Some(buffer) if buffer.account_id.starts_with("telegram:") => {
|
||||
// For everyone unless the window says it is only to be removed from this account's view.
|
||||
let everyone = params.get("forEveryone").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
match backend::telegram::send::delete(state, &buffer, msg_id, everyone).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("kick:") => {
|
||||
match (kick_credential(state, &buffer.account_id), state.runtime.kick_channel(buffer_id)) {
|
||||
(Err(e), _) => (None, Some(e)),
|
||||
@@ -4568,39 +4419,6 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Signing in to Telegram takes place over time - Telegram sends a code, and may then ask for the account's
|
||||
// cloud password - so this answers with an id and the rest is events: see backend/telegram/login.rs.
|
||||
"addTelegramAccount" => {
|
||||
let (Some(api_id), Some(api_hash), Some(phone)) = (params.get("apiId").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))), p_str_opt(params, "apiHash"), p_str_opt(params, "phone")) else {
|
||||
return (None, Some("addTelegramAccount requires \"apiId\", \"apiHash\" and \"phone\"".to_string()));
|
||||
};
|
||||
let Ok(api_id) = i32::try_from(api_id) else {
|
||||
return (None, Some("that api_id is not a number Telegram would have given".to_string()));
|
||||
};
|
||||
let login_id = format!("telegram-{}", crate::model::next_message_id());
|
||||
backend::telegram::login::start(state.clone(), login_id.clone(), api_id, api_hash.trim().to_string(), phone.to_string());
|
||||
(Some(serde_json::json!({ "loginId": login_id })), None)
|
||||
}
|
||||
|
||||
// The code Telegram sent, and the cloud password it may ask for next, for a sign-in that is waiting.
|
||||
"submitTelegramCode" | "submitTelegramPassword" => {
|
||||
let (Some(login_id), Some(value)) = (p_str_opt(params, "loginId"), p_str_opt(params, "value")) else {
|
||||
return (None, Some(format!("{method} requires \"loginId\" and \"value\"")));
|
||||
};
|
||||
let given = if method == "submitTelegramCode" { backend::telegram::login::submit_code(login_id, value) } else { backend::telegram::login::submit_password(login_id, value) };
|
||||
match given {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
"cancelTelegramLogin" => {
|
||||
if let Some(login_id) = p_str_opt(params, "loginId") {
|
||||
backend::telegram::login::cancel(login_id);
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
|
||||
// The form was closed with a sign-in still waiting.
|
||||
"cancelSteamLogin" => {
|
||||
if let Some(login_id) = p_str_opt(params, "loginId") {
|
||||
@@ -5133,12 +4951,6 @@ pub async fn dispatch(
|
||||
// running tally is what most people mean by a poll, and a hidden
|
||||
// one is the deliberate choice.
|
||||
let disclosed = params.get("disclosed").and_then(Value::as_bool).unwrap_or(true);
|
||||
if buffer.account_id.starts_with("telegram:") {
|
||||
return match backend::telegram::polls::start(state, &buffer, question, &answers, disclosed).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
}
|
||||
match backend::matrix::start_poll(state, &buffer.account_id, buffer_id, question, &answers, disclosed).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
@@ -5189,15 +5001,6 @@ pub async fn dispatch(
|
||||
};
|
||||
}
|
||||
}
|
||||
if channel_account(state, buffer_id).starts_with("telegram:") {
|
||||
let (Some(choice), Some(buffer)) = (p_str_opt(params, "answerId"), state.runtime.get_buffer(buffer_id)) else {
|
||||
return (None, Some("votePoll requires \"answerId\"".to_string()));
|
||||
};
|
||||
return match backend::telegram::polls::vote(state, &buffer, choice).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
}
|
||||
if channel_account(state, buffer_id).starts_with("twitch:") {
|
||||
// Twitch's choices are named by ids of their own, which the window hands back as text.
|
||||
let Some(choice) = p_str_opt(params, "answerId") else {
|
||||
@@ -5730,23 +5533,6 @@ pub async fn dispatch(
|
||||
// bufferId (not roomId) throughout, matching every other message-
|
||||
// scoped Matrix method - the buffer->room_id lookup happens inside
|
||||
// moderation.rs itself, same as sendMessage/editMessage/etc.
|
||||
// What this Telegram account may do to others in a conversation, and doing it.
|
||||
"telegramPermissions" | "moderateTelegramMember" => {
|
||||
let Some(buffer) = p_str_opt(params, "bufferId").and_then(|b| state.runtime.get_buffer(b)).filter(|b| b.account_id.starts_with("telegram:")) else {
|
||||
return (None, Some(format!("{method} requires a Telegram \"bufferId\"")));
|
||||
};
|
||||
if method == "telegramPermissions" {
|
||||
return match backend::telegram::admin::permissions(state, &buffer).await {
|
||||
Ok(permissions) => (Some(permissions), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
}
|
||||
match backend::telegram::admin::moderate(state, &buffer, p_str(params, "userId", ""), p_str(params, "action", "")).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
"getMatrixRoomPermissions" => {
|
||||
let (account_id, buffer_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "bufferId")) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
|
||||
+1
-52
@@ -543,8 +543,6 @@ pub struct Runtime {
|
||||
/// Each connected Steam account's connection, by account id: how a message
|
||||
/// or a status change reaches Steam from outside the connection's own task.
|
||||
steam_links: Mutex<HashMap<String, crate::backend::steam::link::Link>>,
|
||||
/// A connected Telegram account's client, by account id (see backend/telegram).
|
||||
telegram_handles: Mutex<HashMap<String, crate::backend::telegram::Handle>>,
|
||||
/// Who has asked to be a Steam account's friend, and who it has asked:
|
||||
/// what the window is shown on the account's page, kept so a window opened
|
||||
/// after the request arrived can still be told.
|
||||
@@ -1060,10 +1058,6 @@ fn cap_cache<K: std::hash::Hash + Eq, V>(map: &mut HashMap<K, V>, limit: usize)
|
||||
/// "Channels" and "Direct Messages" (whispers), the second drawn only while there is a whisper to put under it.
|
||||
/// They are the service's own headings, so they cannot be renamed, removed or filled by hand.
|
||||
fn fixed_category(account_id: &str, kind: &str) -> Option<String> {
|
||||
if account_id.starts_with("telegram:") {
|
||||
// Groups are the usual channel; a broadcast channel moves to its own heading when its buffer is made.
|
||||
return Some(if kind == "dm" { "Direct Messages".to_string() } else { "Groups".to_string() });
|
||||
}
|
||||
account_id.starts_with("twitch:").then(|| if kind == "dm" { "Direct Messages".to_string() } else { "Channels".to_string() })
|
||||
}
|
||||
|
||||
@@ -1112,7 +1106,6 @@ impl Runtime {
|
||||
twitch_senders: Mutex::new(HashMap::new()),
|
||||
twitch_rights: Mutex::new(HashMap::new()),
|
||||
steam_links: Mutex::new(HashMap::new()),
|
||||
telegram_handles: Mutex::new(HashMap::new()),
|
||||
steam_requests: Mutex::new(HashMap::new()),
|
||||
kick_channels: Mutex::new(HashMap::new()),
|
||||
kick_streams: Mutex::new(HashMap::new()),
|
||||
@@ -1263,11 +1256,6 @@ impl Runtime {
|
||||
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
|
||||
crate::accounts::twitch_account_to_json(cfg, conn)
|
||||
}));
|
||||
out.extend(state.accounts.all_telegram().iter().map(|cfg| {
|
||||
let id = cfg.account_id();
|
||||
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
|
||||
crate::accounts::telegram_account_to_json(cfg, conn)
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1705,7 +1693,6 @@ impl Runtime {
|
||||
favourite: false,
|
||||
low_priority: false,
|
||||
service_room: false,
|
||||
name_icon: None,
|
||||
topic: None,
|
||||
read_only: None,
|
||||
forum: false,
|
||||
@@ -1772,23 +1759,6 @@ impl Runtime {
|
||||
/// One way only. A homeserver does not un-designate its notices room, and
|
||||
/// a client that cleared the mark because one sync happened not to carry
|
||||
/// the tag would offer a Leave that the server refuses.
|
||||
/// Draws a glyph in front of a conversation's name in the list (see `Buffer::name_icon`), said once it changes.
|
||||
pub fn set_buffer_name_icon(&self, state: &AppState, buffer_id: &str, icon: Option<&str>) {
|
||||
let updated = {
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
match buffers.get_mut(buffer_id) {
|
||||
Some(b) if b.name_icon.as_deref() != icon => {
|
||||
b.name_icon = icon.map(String::from);
|
||||
Some(b.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(b) = updated {
|
||||
state.events.emit("bufferListChange", serde_json::to_value(&b).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_buffer_service_room(&self, state: &AppState, buffer_id: &str) {
|
||||
let updated = {
|
||||
let mut buffers = self.buffers.lock().unwrap();
|
||||
@@ -4375,18 +4345,6 @@ impl Runtime {
|
||||
}
|
||||
|
||||
/// Registers a signed-in Steam connection.
|
||||
pub fn set_telegram_handle(&self, account_id: &str, handle: crate::backend::telegram::Handle) {
|
||||
self.telegram_handles.lock().unwrap().insert(account_id.to_string(), handle);
|
||||
}
|
||||
|
||||
pub fn clear_telegram_handle(&self, account_id: &str) {
|
||||
self.telegram_handles.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
pub fn telegram_handle(&self, account_id: &str) -> Option<crate::backend::telegram::Handle> {
|
||||
self.telegram_handles.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn set_steam_link(&self, account_id: &str, link: crate::backend::steam::link::Link) {
|
||||
self.steam_links.lock().unwrap().insert(account_id.to_string(), link);
|
||||
}
|
||||
@@ -4872,15 +4830,6 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Says who and what a reply answered, once it has been looked up (a reply to something not read here yet).
|
||||
pub fn fill_reply_preview(&self, state: &AppState, buffer_id: &str, msg_id: &str, from: &str, body: &str) {
|
||||
match state.store.set_reply_text(buffer_id, msg_id, from, body) {
|
||||
Ok(true) => state.events.emit("messageReplyRenamed", json!({ "bufferId": buffer_id, "id": msg_id, "from": from, "body": body })),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("failed to fill in a reply: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discord's MESSAGE_DELETE - removes the row and tells clients to
|
||||
/// drop it from view, same as Discord's own clients (no tombstone).
|
||||
/// A message the service took down, kept and marked as taken down. Quiet
|
||||
@@ -5670,7 +5619,7 @@ impl Runtime {
|
||||
discord_channels, discord_gateway_sessions, ringing_calls,
|
||||
discord_guild_id, discord_buffer_emojis, emoji_catalogue,
|
||||
kick_watching, matrix_emoticons, discord_friends,
|
||||
sneedchat_senders, kick_senders, twitch_senders, twitch_rights, steam_links, telegram_handles, steam_requests, kick_channels,
|
||||
sneedchat_senders, kick_senders, twitch_senders, twitch_rights, steam_links, steam_requests, kick_channels,
|
||||
kick_streams, kick_stream_fetched, live_cards,
|
||||
irc_channel_lists, irc_channel_directory, irc_caps, irc_metadata,
|
||||
discord_last_interaction, matrix_room_creators, matrix_room_versions,
|
||||
|
||||
+12
-48
@@ -193,8 +193,6 @@ impl Store {
|
||||
// A message the service took down and this client kept - see
|
||||
// model::Moderated. JSON, and null for a message nobody removed.
|
||||
"ALTER TABLE messages ADD COLUMN moderated TEXT",
|
||||
// The conversation a reply's target is in, when it is another - see model::ReplyPreview::chat.
|
||||
"ALTER TABLE messages ADD COLUMN reply_chat TEXT",
|
||||
] {
|
||||
let _ = conn.execute(stmt, []);
|
||||
}
|
||||
@@ -451,8 +449,8 @@ impl Store {
|
||||
// OR IGNORE against the (buffer_id, msg_id) index: recording the
|
||||
// same message twice is a backend replaying history it already
|
||||
// has, and the right answer is to keep the copy already stored.
|
||||
"INSERT OR IGNORE INTO messages (msg_id, buffer_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, buffer_kind, sender_color, badges, reply_is_thread, reply_forwarded, reply_thread_root, reply_chat)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, COALESCE(?12, '[]'), ?13, ?14, COALESCE(?15, '[]'), ?16, COALESCE(?17, '[]'), ?18, ?19, ?20, COALESCE(?21, '[]'), ?22, ?23, ?24, ?25)",
|
||||
"INSERT OR IGNORE INTO messages (msg_id, buffer_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, buffer_kind, sender_color, badges, reply_is_thread, reply_forwarded, reply_thread_root)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, COALESCE(?12, '[]'), ?13, ?14, COALESCE(?15, '[]'), ?16, COALESCE(?17, '[]'), ?18, ?19, ?20, COALESCE(?21, '[]'), ?22, ?23, ?24)",
|
||||
params![
|
||||
msg_id,
|
||||
buffer_id,
|
||||
@@ -478,7 +476,6 @@ impl Store {
|
||||
reply_to.is_some_and(|r| r.thread) as i32,
|
||||
reply_to.is_some_and(|r| r.forwarded) as i32,
|
||||
reply_to.and_then(|r| r.thread_root.as_deref()),
|
||||
reply_to.and_then(|r| r.chat.as_deref()),
|
||||
],
|
||||
)?;
|
||||
// Whether this was actually new. The insert has always ignored a
|
||||
@@ -522,16 +519,6 @@ impl Store {
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Fills in who and what a reply answered, once that has been looked up.
|
||||
pub fn set_reply_text(&self, buffer_id: &str, msg_id: &str, from: &str, body: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let changed = conn.execute(
|
||||
"UPDATE messages SET reply_to_from = ?1, reply_to_body = ?2 WHERE buffer_id = ?3 AND msg_id = ?4",
|
||||
params![from, body, buffer_id, msg_id],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Puts the cards on a message without marking it edited: a link being
|
||||
/// described after the fact is not something its sender did.
|
||||
pub fn set_message_embeds(&self, buffer_id: &str, msg_id: &str, embeds: &[Embed]) -> Result<bool> {
|
||||
@@ -793,8 +780,7 @@ impl Store {
|
||||
body: row.get::<_, Option<String>>("reply_to_body").ok().flatten().unwrap_or_default(),
|
||||
thread: row.get::<_, Option<i64>>("reply_is_thread").ok().flatten().unwrap_or(0) != 0,
|
||||
forwarded: row.get::<_, Option<i64>>("reply_forwarded").ok().flatten().unwrap_or(0) != 0,
|
||||
thread_root: row.get::<_, Option<String>>("reply_thread_root").ok().flatten(),
|
||||
chat: row.get::<_, Option<String>>("reply_chat").ok().flatten() });
|
||||
thread_root: row.get::<_, Option<String>>("reply_thread_root").ok().flatten() });
|
||||
let json_column = |name: &str| -> String {
|
||||
row.get::<_, Option<String>>(name).ok().flatten().unwrap_or_else(|| "[]".to_string())
|
||||
};
|
||||
@@ -837,7 +823,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 200 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, reply_chat, moderated
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND (?2 <= 0 OR ts < ?2)
|
||||
ORDER BY ts DESC LIMIT ?3",
|
||||
@@ -913,7 +899,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 200 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, reply_chat, moderated
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ts > ?2
|
||||
ORDER BY ts ASC LIMIT ?3",
|
||||
@@ -936,7 +922,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 500 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, reply_chat, moderated
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ts > ?2 AND ts <= ?3
|
||||
ORDER BY ts ASC LIMIT ?4",
|
||||
@@ -977,7 +963,7 @@ impl Store {
|
||||
pub fn messages_around(&self, buffer_id: &str, ts: i64, span: i64) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let span = if span > 0 { span } else { 50 };
|
||||
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, reply_chat, moderated";
|
||||
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated";
|
||||
|
||||
let mut older = conn.prepare(&format!(
|
||||
"SELECT {COLUMNS} FROM messages WHERE buffer_id = ?1 AND ts <= ?2 ORDER BY ts DESC LIMIT ?3"
|
||||
@@ -1023,7 +1009,7 @@ impl Store {
|
||||
format!(" OR (buffer_kind IS NULL AND buffer_id IN ({places}))")
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, reply_chat, moderated, buffer_id
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated, buffer_id
|
||||
FROM messages
|
||||
WHERE is_highlight = 1 AND is_own = 0 AND (buffer_kind = 'channel'{legacy})
|
||||
ORDER BY ts DESC LIMIT ?"
|
||||
@@ -1284,7 +1270,7 @@ impl Store {
|
||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{escaped}%");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, reply_chat, moderated
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND body LIKE ?2 ESCAPE '\\'
|
||||
ORDER BY ts DESC LIMIT ?3",
|
||||
@@ -1305,13 +1291,7 @@ fn drop_missing_local_copies(attachments: &mut [Attachment]) {
|
||||
let gone = |p: &Option<String>| -> bool {
|
||||
let Some(p) = p.as_deref() else { return false };
|
||||
let Some(rest) = p.strip_prefix("file://") else { return false };
|
||||
// Telegram's are named before they are fetched, and fetched when the window asks (see backend/telegram/media.rs):
|
||||
// a file that is not there yet is the usual state of one, not a sign it was swept.
|
||||
let path = std::path::Path::new(rest);
|
||||
if path.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()).is_some_and(|n| n == crate::backend::telegram::media::MEDIA || n == crate::backend::telegram::media::AVATARS) {
|
||||
return false;
|
||||
}
|
||||
!path.exists()
|
||||
!std::path::Path::new(rest).exists()
|
||||
};
|
||||
for att in attachments.iter_mut() {
|
||||
if gone(&att.thumbnail_path) {
|
||||
@@ -1355,7 +1335,7 @@ impl Store {
|
||||
pub fn thread_messages(&self, buffer_id: &str, root_id: &str) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, reply_chat, moderated
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ((reply_to_id = ?2 AND reply_is_thread = 1) OR reply_thread_root = ?2 OR msg_id = ?2)
|
||||
ORDER BY ts ASC, rowid ASC",
|
||||
@@ -1367,7 +1347,7 @@ impl Store {
|
||||
pub fn get_message(&self, buffer_id: &str, msg_id: &str) -> Result<Option<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, reply_chat, moderated
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND msg_id = ?2",
|
||||
)?;
|
||||
@@ -2020,22 +2000,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_telegram_file_not_fetched_yet_is_still_advertised() {
|
||||
// Its name is given when the message is recorded and the file is fetched when the window asks, so
|
||||
// "not there" is how it starts and not a sign it was swept.
|
||||
let mut attachments = vec![Attachment {
|
||||
path: Some("file:///home/someone/.cache/nobilis/telegram-media/tg_1_n1_2_full.jpg".to_string()),
|
||||
thumbnail_path: Some("file:///home/someone/.cache/nobilis/telegram-media/tg_1_n1_2_thumb.jpg".to_string()),
|
||||
..Default::default()
|
||||
}];
|
||||
drop_missing_local_copies(&mut attachments);
|
||||
assert!(attachments[0].path.is_some() && attachments[0].thumbnail_path.is_some());
|
||||
let mut swept = vec![Attachment { path: Some("file:///home/someone/.cache/nobilis/matrix-media/gone.jpg".to_string()), ..Default::default() }];
|
||||
drop_missing_local_copies(&mut swept);
|
||||
assert!(swept[0].path.is_none(), "another service's swept file is still dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_url_is_left_alone() {
|
||||
// Only local copies are checked; a https link is not this code's
|
||||
|
||||
Reference in New Issue
Block a user