53 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 ec34657d92 Merge development into master: 1.3.2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:36:05 -04:00
SalastilandClaude Sonnet 5.5 e9ce99fb42 1.3.2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:36:03 -04:00
SalastilandClaude Sonnet 5.5 7943767c47 Discord: safeguards after Dissent's (#338): paced reads that obey rate limits, capabilities in IDENTIFY, no history fetched until a conversation is opened, one member-list subscription a connection, own membership from READY, acks only of what is read, text checked before it is sent
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 23:23:16 -04:00
SalastilandClaude Sonnet 5.5 99253cbd5b Merge development into master: 1.3.1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 21:03:46 -04:00
SalastilandClaude Sonnet 5.5 04e4e85f97 1.3.1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 21:03:44 -04:00
SalastilandClaude Sonnet 5.5 b9a79e4b9c Discord: no first word from this client (a server the account has not spoken in, a conversation with nothing in it, are locked), a nonce and allowed_mentions on every message, files checked against the upload limit, a pause between a message and a file
The account's own messages since it joined are looked for in what is stored and then in Discord's search (one request a server, kept); the messages this client sent do not count. The lock is refused in the daemon and shown by the window as the conversation's read-only reason.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 20:39:40 -04:00
SalastilandClaude Sonnet 5.5 264ee7a1f5 Merge development into master: 1.3.0, Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:03:03 -04:00
SalastilandClaude Sonnet 5.5 6345ac4df6 1.3.0
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:02:54 -04:00
SalastilandClaude Sonnet 5.5 3bf68e747a Discord: send the install id, locale and timezone with API requests
X-Installation-ID (random, made once, kept in the data dir), X-Discord-Locale,
X-Discord-Timezone and Accept-Language on the API client; system_locale in
IDENTIFY. The user agent stays moho/<version> (nobilis); no super properties.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 18:49:18 -04:00
SalastilandClaude Sonnet 5.5 e8b25d99bf Discord: resume across restarts, ask only about DMs that moved on, throttle typing, skip repeat acks
A restart within 150 s of the last heartbeat ack resumes the saved session
(READY and GUILD_CREATE kept in discord-sessions/, replayed through the live
handlers) instead of logging in afresh; a refused resume or a 4003/4007/4009
close discards it. DM catch-up compares last_message_id with what is stored
and skips the rest; typing posts are at most one per 8 s per channel; an ack
is not repeated for the same newest message.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 18:19:19 -04:00
SalastilandClaude Sonnet 5.5 58bf5ac83a Discord: Connect anyway lets a held login through (still counted)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 23:52:42 -04:00
SalastilandClaude Sonnet 5.5 8a7d73346e Discord: a limit on fresh logins per account (3 an hour, 8 a day, kept across restarts); the connection is held and the window says why
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 23:41:56 -04:00
SalastilandClaude Sonnet 5.5 54f3e3f697 Telegram: the GIF picker - the account's saved GIFs and Telegram's GIF search, sent as the files they are
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:41:45 -04:00
SalastilandClaude Sonnet 5.5 931cd458af Merge development into master: Telegram
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:47:35 -04:00
SalastilandClaude Sonnet 5.5 ad597ea2d2 Telegram: tests use made-up ids
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:47:29 -04:00
SalastilandClaude Sonnet 5.5 e4c4fd756f Telegram: a channel's post carries the channel's picture, and a picture or video is not also named in words
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:20:32 -04:00
SalastilandClaude Sonnet 5.5 7f7f25761c Telegram: each forum topic is a conversation of its own under its group's rail entry
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:13:29 -04:00
SalastilandClaude Sonnet 5.5 50ca7b4255 Telegram: each group is its own entry on the rail, with its name and picture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:00:56 -04:00
SalastilandClaude Sonnet 5.5 55016757d3 Discord: the platform's own account carries the OFFICIAL mark
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 17:30:53 -04:00
SalastilandClaude Sonnet 5.5 5d17b2397a Telegram accounts are Telegram, not IRC: the service with no prefix is IRC and a prefix not on the list fell into it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:15:20 -04:00
SalastilandClaude Sonnet 5.5 eae7add558 Telegram: a bot chat offers only the bot's own commands
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:06:14 -04:00
SalastilandClaude Sonnet 5.5 8e5e641446 Telegram: a bot's command menu - the Menu button and the commands offered while a slash is typed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 13:58:48 -04:00
SalastilandClaude Sonnet 5.5 a79749f8d7 Telegram: say what markup a message carried when it gave no buttons
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 13:53:35 -04:00
SalastilandClaude Sonnet 5.5 13b0a5c6af Telegram: a bot's inline keyboard - callback buttons answered with the bot's notice, links, edits that change or remove them
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 13:40:17 -04:00
SalastilandClaude Sonnet 5.5 dba773160f Discord: bots and applications carry Discord's APP mark (verified ones with a check), in history and live
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 00:01:47 -04:00
SalastilandClaude Sonnet 5.5 41c2780aaa Discord: messages made with layout components (containers, sections, text, pictures) are drawn as cards with their buttons
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 23:53:46 -04:00
SalastilandClaude Sonnet 5.5 63093e1f1a Discord: the server's rules dialog shows the rules and any questions, and sends the answers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 20:28:43 -04:00
SalastilandClaude Sonnet 5.5 65635276f3 Telegram: a forwarded message says where it came from, and a post in an open channel is a click away
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 20:07:35 -04:00
SalastilandClaude Sonnet 5.5 5094a9a2ec Telegram: an answer to a post in another channel is drawn as Telegram draws it and goes to the original
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:45:55 -04:00
SalastilandClaude Sonnet 5.5 7a4b0130a0 Telegram: a horn before broadcast channels and a robot before bots in the list
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:36:54 -04:00
SalastilandClaude Sonnet 5.5 ee0e0f921b Telegram: bots have a heading of their own
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:32:07 -04:00
SalastilandClaude Sonnet 5.5 a6f35525d0 Telegram: an article's cover is found among the page's own picture, and a missing picture keeps its caption
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 15:27:28 -04:00
SalastilandClaude Sonnet 5.5 b1d1a53c2f Telegram: Instant View articles drawn as a document of their own; link cards say so
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 15:22:23 -04:00
SalastilandClaude Sonnet 5.5 15d9058604 Telegram: link previews (what Instant View pages carry) as cards with their picture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 15:09:43 -04:00
SalastilandClaude Sonnet 5.5 b4ddb310b6 Telegram: a file not fetched yet keeps its place in a message (it was being dropped as swept)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 10:03:45 -04:00
SalastilandClaude Sonnet 5.5 d368413241 Telegram: each download has its own half-written file; a restore waits for a preview already on its way
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:55:50 -04:00
SalastilandClaude Sonnet 5.5 5037705cc4 Telegram: previews are fetched as messages are recorded
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:47:29 -04:00
SalastilandClaude Sonnet 5.5 f725dcc2da Telegram: pictures no longer wait behind whole-file downloads; failures to fetch are logged
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:38:01 -04:00
SalastilandClaude Sonnet 5.5 4643cb082e Telegram: a message kept without its media gets it when read again
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:30:15 -04:00
SalastilandClaude Sonnet 5.5 11ce3e974b Telegram: messages from a person in a private chat are recorded (short updates name no one)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 09:23:12 -04:00
SalastilandClaude Sonnet 5.5 0f5003c061 Telegram: start polls, forum messages are not replies to their topic
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:23:49 -04:00
SalastilandClaude Sonnet 5.5 ca47ffeae7 Telegram: join and leave, admin actions on members, mute synced with the account
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:17:18 -04:00
SalastilandClaude Sonnet 5.5 6a8c35fe14 Telegram: polls, thread replies and channel comments
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:08:09 -04:00
SalastilandClaude Sonnet 5.5 584e3b8744 Telegram: reactions, pins, members, profile cards, server search, jumping to old messages
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:59:03 -04:00
Salastil 84258f4381 Telegram: pictures, video, files, voice and stickers - fetched when looked at - and sending files, albums and voice messages 2026-10-15 23:53:15 -04:00
SalastilandClaude Sonnet 5.5 3fc8cd1a1d Telegram: send, reply, edit, delete, typing both ways, read positions, formatting as entities
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:46:34 -04:00
SalastilandClaude Sonnet 5.5 22f838417f Telegram: a conversation the account cannot write in shows why in place of the message box
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:36:21 -04:00
SalastilandClaude Sonnet 5.5 d027d2e39f Telegram: conversations as buffers, history on opening and scrolling back, live messages, edits and deletions
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:27:29 -04:00
SalastilandClaude Sonnet 5.5 537ee520cd Telegram: accounts, sessions in files of their own, sign-in by phone code and cloud password, a connection that is kept and reconnected
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 23:17:27 -04:00
SalastilandClaude Sonnet 5.5 5ab8eafceb Merge development into master: 1.2.1, Steam identity
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 17:14:18 -04:00
SalastilandClaude Sonnet 5.5 e4a16278ab 1.2.1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 17:14:12 -04:00
SalastilandClaude Sonnet 5.5 f3de870d49 Steam: sign in as a desktop client with a stable machine id and a logon id of this install's own, and wait longer before reconnecting
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 01:01:33 -04:00
SalastilandClaude Sonnet 5.5 2355fcb6ac Merge development into master: 1.2.0, Twitch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:54:56 -04:00
66 changed files with 9484 additions and 247 deletions
Generated
+662 -20
View File
@@ -841,6 +841,16 @@ 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"
@@ -944,13 +954,28 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.0"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
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"
@@ -997,6 +1022,19 @@ 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"
@@ -1019,6 +1057,16 @@ 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"
@@ -1274,6 +1322,37 @@ 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"
@@ -1565,7 +1644,7 @@ dependencies = [
"ed25519 3.0.0",
"rand_core 0.10.1",
"serde",
"sha2 0.11.0",
"sha2 0.11.1",
"signature 3.0.0",
"subtle",
"zeroize",
@@ -1596,7 +1675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct 0.2.0",
"crypto-bigint",
"crypto-bigint 0.5.5",
"digest 0.10.7",
"ff",
"generic-array",
@@ -1630,7 +1709,7 @@ version = "3.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee"
dependencies = [
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
"proc-macro2",
"quote",
@@ -1813,12 +1892,13 @@ checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
[[package]]
name = "flate2"
version = "1.1.9"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
@@ -2101,6 +2181,22 @@ 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"
@@ -2119,6 +2215,121 @@ 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"
@@ -2210,7 +2421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc"
dependencies = [
"hax-lib-macros",
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
]
@@ -2264,6 +2475,76 @@ 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"
@@ -2318,7 +2599,7 @@ dependencies = [
"hkdf 0.13.0",
"hybrid-array",
"rand_core 0.10.1",
"sha2 0.11.0",
"sha2 0.11.1",
"subtle",
"x25519-dalek 3.0.0",
"zeroize",
@@ -2802,11 +3083,27 @@ 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"
@@ -2876,6 +3173,60 @@ 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"
@@ -3332,6 +3683,15 @@ 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"
@@ -3485,7 +3845,7 @@ dependencies = [
"ruma",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.11.1",
"subtle",
"thiserror 2.0.21",
"time",
@@ -3557,11 +3917,17 @@ dependencies = [
"serde",
"serde_bytes",
"serde_json",
"sha2 0.11.0",
"sha2 0.11.1",
"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"
@@ -3621,6 +3987,16 @@ 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"
@@ -3633,6 +4009,23 @@ 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"
@@ -3684,9 +4077,21 @@ 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.0"
version = "1.3.2"
dependencies = [
"aes-gcm",
"anyhow",
@@ -3703,11 +4108,15 @@ dependencies = [
"flate2",
"fs2",
"futures",
"grammers-client",
"grammers-mtsender",
"grammers-session",
"hex",
"hmac 0.13.0",
"http-body-util",
"hyper",
"hyper-util",
"iana-time-zone",
"image",
"imagesize",
"irc",
@@ -3818,6 +4227,17 @@ 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"
@@ -3947,6 +4367,17 @@ 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"
@@ -3970,6 +4401,16 @@ 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"
@@ -3983,6 +4424,51 @@ 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"
@@ -4012,11 +4498,69 @@ 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"
@@ -4143,6 +4687,21 @@ 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"
@@ -4284,6 +4843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
dependencies = [
"digest 0.11.3",
"hmac 0.13.0",
]
[[package]]
@@ -4452,7 +5012,7 @@ dependencies = [
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
"miniz_oxide 0.8.9",
]
[[package]]
@@ -4499,6 +5059,21 @@ 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"
@@ -4544,6 +5119,17 @@ 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"
@@ -5044,6 +5630,12 @@ 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"
@@ -5512,7 +6104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -5738,9 +6330,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -6270,6 +6862,33 @@ 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"
@@ -6428,9 +7047,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
@@ -8009,7 +8628,7 @@ dependencies = [
"serde",
"serde_bytes",
"serde_json",
"sha2 0.11.0",
"sha2 0.11.1",
"subtle",
"thiserror 2.0.21",
"x25519-dalek 3.0.0",
@@ -8219,6 +8838,12 @@ 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"
@@ -8339,6 +8964,17 @@ 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"
@@ -8784,6 +9420,12 @@ 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"
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nobilis"
version = "1.2.0"
version = "1.3.2"
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,6 +59,12 @@ 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"] }
@@ -121,6 +127,7 @@ sonora = "0.2"
# Soundboard sounds are MP3 or Ogg Vorbis, decoded here to be mixed into a call.
symphonia = { version = "0.5", default-features = false, features = ["mp3", "ogg", "vorbis"] }
imagesize = "0.13"
iana-time-zone = "0.1.65"
# Two calls, both in main.rs: see keep_the_heap_honest() there for why a chat
# daemon has anything to say to its allocator.
+222 -6
View File
@@ -390,9 +390,8 @@ 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. 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.
/// 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.
#[serde(default)]
pub session: String,
/// Whether this account's connections go through Tor or the SOCKS5 proxy
@@ -490,6 +489,54 @@ 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")]
@@ -510,6 +557,8 @@ 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,
@@ -529,6 +578,8 @@ 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 {
@@ -539,7 +590,7 @@ impl AccountStore {
}
pub fn open(path: PathBuf) -> Result<Self> {
let (irc, discord, sneedchat, matrix, kick, steam, twitch) = if path.exists() {
let (irc, discord, sneedchat, matrix, kick, steam, twitch, telegram) = if path.exists() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let file: AccountsFile = toml::from_str(&text)
@@ -551,9 +602,10 @@ 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();
(irc, discord, sneedchat, matrix, kick, steam, twitch)
let telegram = file.telegram.into_iter().map(|a| (a.account_id(), a)).collect();
(irc, discord, sneedchat, matrix, kick, steam, twitch, telegram)
} 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,
@@ -564,6 +616,7 @@ impl AccountStore {
kick: Mutex::new(kick),
steam: Mutex::new(steam),
twitch: Mutex::new(twitch),
telegram: Mutex::new(telegram),
})
}
@@ -586,6 +639,7 @@ 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
@@ -823,6 +877,48 @@ 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> {
@@ -965,6 +1061,10 @@ 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
};
@@ -995,6 +1095,9 @@ 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))
}
@@ -1082,6 +1185,13 @@ 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)?;
@@ -1246,6 +1356,9 @@ 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),
@@ -1282,6 +1395,9 @@ 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))
}
@@ -1627,6 +1743,50 @@ 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
@@ -1881,3 +2041,59 @@ 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");
}
}
+2 -2
View File
@@ -31,7 +31,7 @@ pub async fn list_commands(state: &AppState, account_id: &str, buffer_id: &str,
.get(format!("{API_BASE}/channels/{channel_id}/application-commands/search"))
.query(&params)
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("reading the commands")?;
if !resp.status().is_success() {
@@ -243,7 +243,7 @@ pub async fn use_component(
let resp = http_client_for(&cfg.token)
.get(format!("{API_BASE}/channels/{channel_id}/messages/{message_id}"))
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("finding out whose button that is")?;
if !resp.status().is_success() {
+3 -3
View File
@@ -135,7 +135,7 @@ pub async fn list(state: &AppState, account_id: &str, guild_id: &str) -> Result<
.get(format!("{API_BASE}/guilds/{guild_id}/scheduled-events"))
.query(&[("with_user_count", "true")])
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("reading the events")?;
if resp.status().is_success() {
@@ -153,7 +153,7 @@ pub async fn list(state: &AppState, account_id: &str, guild_id: &str) -> Result<
.get(format!("{API_BASE}/guilds/{guild_id}/scheduled-events/{id}/users"))
.query(&[("limit", "100")])
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
{
if let Ok(users) = resp.json::<Value>().await {
@@ -380,7 +380,7 @@ pub async fn share_targets(state: &AppState, account_id: &str, guild_id: &str) -
.get(format!("{API_BASE}/users/{user_id}/profile"))
.query(&[("with_mutual_guilds", "true")])
.header("Authorization", &token)
.send()
.send_paced()
.await
{
if let Ok(profile) = resp.json::<Value>().await {
+2 -2
View File
@@ -125,7 +125,7 @@ async fn search_page(token: &str, channel_id: &str, sort_by: &str, archived: boo
.get(format!("{API_BASE}/channels/{channel_id}/threads/search"))
.query(&query)
.header("Authorization", token)
.send()
.send_paced()
.await
.context("reading the forum")?;
if !resp.status().is_success() {
@@ -216,7 +216,7 @@ pub async fn list_forum_posts(state: &AppState, account_id: &str, buffer_id: &st
.get(format!("{API_BASE}/channels/{id}/messages"))
.query(&[("limit", "1"), ("after", "0")])
.header("Authorization", token)
.send()
.send_paced()
.await
.ok()?;
if !resp.status().is_success() {
+209 -41
View File
@@ -59,6 +59,22 @@ impl std::fmt::Display for GatewayRateLimited {
impl std::error::Error for GatewayRateLimited {}
/// Close codes that say the session is gone and cannot be resumed: not authenticated, an invalid sequence number, a
/// session that timed out. The next connection logs in afresh.
const SESSION_LOST_CODES: [u16; 3] = [4003, 4007, 4009];
/// The gateway closed the connection and ended the session with it.
#[derive(Debug)]
pub(super) struct SessionLost(u16);
impl std::fmt::Display for SessionLost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "the gateway ended the session (close code {})", self.0)
}
}
impl std::error::Error for SessionLost {}
/// Discord's op 7: move to another server, resuming where this left off.
/// Its own type so the retry loop can tell it from a failure.
#[derive(Debug)]
@@ -86,6 +102,9 @@ where
if frame.as_ref().is_some_and(|f| u16::from(f.code) == CLOSE_CODE_RATE_LIMITED) {
return Err(GatewayRateLimited.into());
}
if let Some(code) = frame.as_ref().map(|f| u16::from(f.code)).filter(|c| SESSION_LOST_CODES.contains(c)) {
return Err(SessionLost(code).into());
}
bail!("connection closed: {frame:?}")
}
Some(Ok(_)) => continue,
@@ -132,7 +151,7 @@ pub fn spawn(state: AppState, config: DiscordAccountConfig) {
state.runtime.insert_task_handle(&account_id, join_handle.abort_handle());
}
pub(super) const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(3);
pub(super) const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(4);
pub(super) const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(60);
@@ -173,6 +192,11 @@ pub(super) struct GatewaySession {
channel_map: HashMap<String, (String, String)>,
/// Each guild's payload as GUILD_CREATE delivered it.
guild_context: HashMap<String, Value>,
/// The session as kept on disk, for a restart to resume.
persist: super::session_store::SessionFile,
/// What a resumed session is not sent again - READY and the GUILD_CREATEs saved from the last run - to be handled
/// as though they had just arrived, before anything is read from the socket.
pending: std::collections::VecDeque<Value>,
}
/// Enough to pick a session back up: what it was called, where to reconnect,
@@ -183,15 +207,59 @@ pub(super) struct Resume {
seq: u64,
}
/// The gateway capabilities this client declares in IDENTIFY, as a user client does (Dissent's `ningen` sends the same
/// five of the seven it knows): notes fetched lazily, read states and guild settings in their versioned form, guild
/// experiments as populations, and read states for things that are not channels. Each changes only the shape of a
/// field READY carries and this client either ignores (`read_state`, notes, experiments) or reads in both shapes
/// (`user_guild_settings`, see `mutes::note_settings`).
///
/// Not declared: DEDUPE_USER_OBJECTS (16) and PRIORITIZED_READY_PAYLOAD (32). They move users out of READY into a
/// table by id and split the rest into a second event, and the friends list, the names of conversations and the
/// presences here are built from the objects that would no longer be there. They need a real READY to be written
/// against, not a guess at one.
pub(super) const CAPABILITIES: u32 = (1 << 0) | (1 << 2) | (1 << 3) | (1 << 6) | (1 << 7);
/// What IDENTIFY says this client is: moho, on Linux, in this machine's language.
fn identify_properties() -> Value {
let mut properties = json!({ "os": "linux", "browser": "nobilis", "device": "nobilis" });
if let Some(locale) = super::install_headers::locale() {
properties["system_locale"] = Value::from(locale);
}
properties
}
pub(super) async fn run_gateway_with_retry(state: &AppState, config: &DiscordAccountConfig, account_id: &str) {
let mut delay = RECONNECT_INITIAL_DELAY;
let mut session = GatewaySession::default();
let mut session = GatewaySession {
persist: super::session_store::SessionFile::new(account_id, &config.user_id, &config.token),
..Default::default()
};
// A restart within a few minutes of the last session picks it up, as Discord's own client does, rather than
// logging in afresh: see session_store.
if let Some((position, replay)) = session.persist.load(&config.token, chrono::Utc::now().timestamp()) {
tracing::info!("discord[{account_id}]: resuming the session from before the restart");
session.resume = Some(Resume { session_id: position.session_id, url: position.url, seq: position.seq });
session.pending = replay.into();
}
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
loop {
// A connection that will log in afresh (not pick a session back up) is counted, and held when the account has
// made too many lately: see identify_budget.
if session.resume.is_none() {
hold_for_login_budget(state, account_id).await;
}
let result = std::panic::AssertUnwindSafe(run_gateway(state, config, &mut session)).catch_unwind().await;
let requested_reconnect =
matches!(&result, Ok(Err(e)) if e.downcast_ref::<ReconnectRequested>().is_some());
let rate_limited = matches!(&result, Ok(Err(e)) if e.downcast_ref::<GatewayRateLimited>().is_some());
// A session Discord has ended is not resumed, here or after a restart: the next connection is a login.
if matches!(&result, Ok(Err(e)) if e.downcast_ref::<SessionLost>().is_some()) {
session.resume = None;
session.persist.clear();
session.pending.clear();
session.channel_map.clear();
session.guild_context.clear();
}
let detail = match result {
Ok(Ok(())) => "gateway session ended".to_string(),
Ok(Err(e)) => {
@@ -229,10 +297,37 @@ pub(super) async fn run_gateway_with_retry(state: &AppState, config: &DiscordAcc
}
}
/// Waits until the account may make a fresh login, saying in the window why it is waiting. The person may let it
/// through sooner ("Connect anyway"); that login is still counted.
async fn hold_for_login_budget(state: &AppState, account_id: &str) {
loop {
match super::identify_budget::ask(account_id) {
Ok(()) => return,
Err(wait) => {
let detail = super::identify_budget::explain(wait);
tracing::warn!("discord[{account_id}]: {detail}");
state.runtime.set_conn_state(state, account_id, ConnState::Connecting, None);
state.runtime.report_retry(state, account_id, &detail, Duration::from_secs(wait.max(1) as u64));
let pass = super::identify_budget::begin_hold(account_id);
let let_through = tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(wait.max(1) as u64)) => false,
_ = pass.notified() => true,
};
super::identify_budget::end_hold(account_id);
if let_through {
tracing::warn!("discord[{account_id}]: connecting past the login limit, as asked");
super::identify_budget::record(account_id);
return;
}
}
}
}
}
pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig, session: &mut GatewaySession) -> Result<()> {
// Disjoint borrows, so the resume state can be updated while the maps
// are being passed around mutably.
let GatewaySession { resume, channel_map, guild_context } = session;
let GatewaySession { resume, channel_map, guild_context, persist, pending } = session;
// Discord asks that a resume go to the url it handed out with the
// session rather than to the front door.
let connect_url = resume.as_ref().map(|r| r.url.clone()).unwrap_or_else(|| GATEWAY_URL.to_string());
@@ -299,6 +394,8 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// Exposed so setAccountStatus can push a presence update onto this
// connection rather than waiting for a reconnect to carry it.
let account_id = config.account_id();
// A connection starts with no subscriptions.
super::presence::forget_member_subscriptions(&account_id);
state.runtime.set_discord_gateway_sender(&account_id, out_tx.clone());
struct ClearSenderOnDrop<'a>(&'a AppState, String);
impl Drop for ClearSenderOnDrop<'_> {
@@ -319,7 +416,8 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
"op": 2,
"d": {
"token": config.token,
"properties": { "os": "linux", "browser": "nobilis", "device": "nobilis" },
"properties": identify_properties(),
"capabilities": CAPABILITIES,
"compress": false,
"large_threshold": 50,
// Carried in IDENTIFY as well as pushed live, so a status set
@@ -347,9 +445,19 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
let mut tally_since = std::time::Instant::now();
loop {
let msg = tokio::select! {
result = next_json(&mut stream) => result?,
_ = stale_notify.notified() => bail!("no heartbeat ack received - connection is zombied"),
// What a resume does not send comes first, from the last run; `replaying` says so to the handlers.
let (msg, replaying) = match pending.front().cloned() {
Some(saved) => {
pending.pop_front();
(saved, true)
}
None => {
let msg = tokio::select! {
result = next_json(&mut stream) => result?,
_ = stale_notify.notified() => bail!("no heartbeat ack received - connection is zombied"),
};
(msg, false)
}
};
let op = msg.get("op").and_then(|v| v.as_i64()).unwrap_or(-1);
// Every dispatch is numbered, and a resume says how far it got.
@@ -411,14 +519,19 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// Also what an interaction has to name - see
// Runtime::set_discord_gateway_session.
state.runtime.set_discord_gateway_session(&account_id, session_id);
*resume = Some(Resume {
let next = Resume {
session_id: session_id.to_string(),
url: d["resume_gateway_url"]
.as_str()
.map(|u| format!("{u}/?v=10&encoding=json"))
.unwrap_or_else(|| GATEWAY_URL.to_string()),
seq: msg.get("s").and_then(|v| v.as_u64()).unwrap_or(0),
});
};
// A new session replaces what was kept; the saved one handled again is that same one.
if !replaying {
persist.ready(&next.session_id, &next.url, next.seq, d.clone());
}
*resume = Some(next);
}
if let Some(hash) = d["user"]["avatar"].as_str() {
let url = format!("https://cdn.discordapp.com/avatars/{}/{hash}.png", config.user_id);
@@ -475,8 +588,13 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
}
let mut new_dm_buffers: Vec<(String, String)> = Vec::new();
// What Discord says each conversation's newest message
// is, so catching up asks only about the ones that
// have something newer than is stored.
let mut newest_in: HashMap<String, Option<String>> = HashMap::new();
if let Some(dms) = d["private_channels"].as_array() {
for ch in dms {
note_newest(&mut newest_in, ch);
if let Some(pair) = register_dm_channel(state, &account_id, ch, channel_map, &presences) {
new_dm_buffers.push(pair);
}
@@ -492,15 +610,11 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
update_presence_in_rosters(state, user_id, p["status"].as_str().unwrap_or("offline"), Some(activity));
}
}
// Conversations are the ones people notice a gap in,
// and there are few enough of them to ask about
// directly. Channels wait until opened - a few hundred
// requests on every connect would be rate-limited and
// mostly wasted.
spawn_dm_catch_up(state.clone(), config.clone(), channel_map.clone());
state.runtime.set_conn_state(state, &account_id, ConnState::Connected, None);
tracing::info!("discord[{account_id}]: ready as {username}");
// Saved ready is not yet accepted by Discord: RESUMED says when it is.
if !replaying {
state.runtime.set_conn_state(state, &account_id, ConnState::Connected, None);
}
tracing::info!("discord[{account_id}]: ready as {username}{}", if replaying { " (from the last run)" } else { "" });
// For an account in relatively few guilds, READY's own
// `guilds` array already carries full guild objects
@@ -515,8 +629,11 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// harmlessly on a guild it's already seen via either
// path (channel_map dedup).
if let Some(guilds) = d["guilds"].as_array() {
for g in guilds {
register_guild_channels(state, config, g, channel_map).await;
// Our own membership of each, from READY's list of them, so that no guild is asked about.
let merged = d["merged_members"].as_array();
for (i, g) in guilds.iter().enumerate() {
let with_me = guild_with_own_member(g, merged.and_then(|m| m.get(i)), &config.user_id);
register_guild_channels(state, config, with_me.as_ref().unwrap_or(g), channel_map).await;
}
}
@@ -526,30 +643,62 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// traffic for channel ids that never appeared there) -
// fetch the full list over REST as a follow-up rather
// than trusting the gateway snapshot alone.
match http_client_for(&config.token).get(format!("{API_BASE}/users/@me/channels")).header("Authorization", &config.token).send().await {
Ok(resp) => {
let body = resp.text().await.unwrap_or_default();
match serde_json::from_str::<Vec<Value>>(&body) {
Ok(channels) => {
for ch in &channels {
if let Some(pair) = register_dm_channel(state, &account_id, ch, channel_map, &presences) {
new_dm_buffers.push(pair);
}
//
// A session picked up again after a restart already
// has that list, kept with the rest of it - asking
// again is a request for nothing.
let listed: Option<Vec<Value>> = if replaying {
d["moho_dms"].as_array().cloned()
} else {
match http_client_for(&config.token).get(format!("{API_BASE}/users/@me/channels")).header("Authorization", &config.token).send_paced().await {
Ok(resp) => {
let body = resp.text().await.unwrap_or_default();
match serde_json::from_str::<Vec<Value>>(&body) {
Ok(channels) => Some(channels),
Err(e) => {
tracing::warn!("discord[{account_id}]: parsing /users/@me/channels: {e}");
None
}
}
Err(e) => tracing::warn!("discord[{account_id}]: parsing /users/@me/channels: {e}"),
}
Err(e) => {
tracing::warn!("discord[{account_id}]: fetching /users/@me/channels: {e}");
None
}
}
Err(e) => tracing::warn!("discord[{account_id}]: fetching /users/@me/channels: {e}"),
};
for ch in listed.iter().flatten() {
note_newest(&mut newest_in, ch);
if let Some(pair) = register_dm_channel(state, &account_id, ch, channel_map, &presences) {
new_dm_buffers.push(pair);
}
}
if !replaying {
persist.dms(listed.unwrap_or_default());
}
// The direct messages now exist, so the settings
// read at the top of READY have somewhere to land.
mutes::apply(state, &account_id);
spawn_backfill(state.clone(), config.token.clone(), config.user_id.clone(), config.display_name.clone(), new_dm_buffers);
// Conversations are the ones people notice a gap in,
// and there are few enough of them to ask about
// directly - and only the ones Discord says have
// moved on. Channels wait until opened: a few hundred
// requests on every connect would be rate-limited and
// mostly wasted.
// (A resumed session is replayed what it missed.)
if !replaying {
spawn_dm_catch_up(state.clone(), config.clone(), channel_map.clone(), newest_in);
}
// Nor is any conversation's history fetched here: it is read when it is opened (see getBacklog).
let _ = new_dm_buffers;
}
"GUILD_CREATE" => {
if let (false, Some(id)) = (replaying, d["id"].as_str()) {
persist.guild(id, d);
}
register_guild_channels(state, config, d, channel_map).await;
// The channels this guild's settings were about now
// exist. READY carried the mutes before any of them
@@ -716,6 +865,10 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// own state rather than needing to guess.
"MESSAGE_ACK" => {
let Some(channel_id) = d["channel_id"].as_str() else { continue };
// Remembered, so what is read here to the same point is not told to Discord a second time.
if let Some(message_id) = d["message_id"].as_str() {
super::send::acked_elsewhere(channel_id, message_id);
}
let Some((name, _)) = channel_map.get(channel_id) else { continue };
state.events.emit(
"bufferRead",
@@ -819,6 +972,12 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
}
"GUILD_DELETE" => {
if let Some(id) = d["id"].as_str() {
persist.guild_gone(id);
if d["unavailable"].as_bool() != Some(true) {
super::send_guard::forget_guild(&account_id, id);
}
}
let Some(guild_id) = d["id"].as_str() else { continue };
if d["unavailable"].as_bool() == Some(true) {
continue;
@@ -838,6 +997,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
tracing::info!("discord[{account_id}]: a message in {channel_id} was dropped - that channel is not one this connection knows ({} are)", channel_map.len());
continue;
};
super::send_guard::heard(state, config, d, &model::buffer_id(&account_id, &buffer_name), kind == "dm");
let author = &d["author"];
let from = author["global_name"]
.as_str()
@@ -862,7 +1022,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
let real_msg_id = d["id"].as_str().map(|s| s.to_string());
let avatar_url = author_avatar_url(author);
if author["system"].as_bool() == Some(true) && kind == "dm" {
state.runtime.set_buffer_read_only(state, &model::buffer_id(&account_id, &buffer_name), Some(super::people::OFFICIAL_ONLY));
super::send_guard::set_permission(state, &account_id, &model::buffer_id(&account_id, &buffer_name), Some(super::people::OFFICIAL_ONLY));
}
// Kept against their id as well as put on the message.
// A direct call's voice states carry no member object,
@@ -891,7 +1051,9 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// The author's id travels with the message. It is what a profile
// lookup asks Discord about - a display name is not something
// the API accepts - and what a moderation action would need.
state.runtime.record_message(state, &account_id, &buffer_name, &kind, &from, &body, false, "chat", reply_to, real_msg_id.clone(), is_mention, avatar_url, embeds, attachments, author["id"].as_str().map(str::to_string));
let badges = author_badges(author);
let style = if badges.is_empty() { None } else { Some(model::SenderStyle { color: None, badges }) };
state.runtime.record_message_at(state, &account_id, &buffer_name, &kind, &from, &body, false, "chat", reply_to, real_msg_id.clone(), is_mention, avatar_url, embeds, attachments, author["id"].as_str().map(str::to_string), None, None, style);
// Who wrote a forwarded message, which the snapshot
// does not say. A read of the original, so it happens
// after the message is already on screen rather than
@@ -1131,12 +1293,10 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
mutes::note_settings(state, &account_id, d);
}
"GUILD_MEMBER_ADD" | "GUILD_MEMBER_REMOVE" => {
let Some(guild_id) = d["guild_id"].as_str() else { continue };
if let Some(buffer_id) = state.runtime.discord_member_list_target(&account_id, guild_id) {
request_member_list(state, &buffer_id);
}
}
// Not a reason to ask for the list again: the list is subscribed to, and Discord sends what changes in
// it. Asking at every join and leave of a busy server was a request for what was already coming.
"GUILD_MEMBER_ADD" | "GUILD_MEMBER_REMOVE" => {}
// A purge. Without this every message in it stayed on
// screen, since the per-message deletes are not sent.
@@ -1358,6 +1518,8 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// holding buffers for channels this account may no longer be in.
9 => {
*resume = None;
persist.clear();
pending.clear();
channel_map.clear();
guild_context.clear();
bail!("session invalidated by gateway")
@@ -1366,7 +1528,13 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// sending the *next* one, so a real ack landing between ticks
// is exactly what keeps this connection from ever being
// declared zombied in the first place.
11 => ack_pending.store(false, Ordering::SeqCst),
11 => {
ack_pending.store(false, Ordering::SeqCst);
// Alive as of now: what a restart soon enough will resume.
if let Some(r) = resume.as_ref() {
persist.acked(r.seq);
}
}
_ => {}
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ pub async fn list_gifs(state: &AppState, query: &str) -> Result<Value> {
let resp = http_client_for(&config.token)
.get(url)
.header("Authorization", &config.token)
.send()
.send_paced()
.await
.context("asking Discord for GIFs")?;
if !resp.status().is_success() {
+117 -21
View File
@@ -96,7 +96,7 @@ pub async fn member_verification(state: &AppState, account_id: &str, guild_id: &
let resp = http_client_for(&cfg.token)
.get(format!("{API_BASE}/guilds/{guild_id}/member-verification"))
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("asking Discord for this server's rules")?;
let status = resp.status();
@@ -107,6 +107,27 @@ 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
@@ -114,20 +135,11 @@ pub async fn member_verification(state: &AppState, account_id: &str, guild_id: &
/// 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) -> Result<()> {
pub async fn accept_member_verification(state: &AppState, account_id: &str, guild_id: &str, answers: &[Value]) -> 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: 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 fields = responses_for(&form, answers);
let resp = send_write(
http_client_for(&cfg.token)
@@ -189,7 +201,7 @@ pub(super) async fn resync_guild_once(state: &AppState, config: &DiscordAccountC
http_client_for(&config.token)
.get(format!("{API_BASE}/{path}"))
.header("Authorization", &config.token)
.send()
.send_paced()
.await
.ok()?
.json::<Value>()
@@ -231,6 +243,32 @@ pub(super) async fn resync_guild_once(state: &AppState, config: &DiscordAccountC
}
}
/// This account's own member object out of READY's `merged_members`, which lists, for each guild in the order READY
/// names them, the account's own membership of it (with a `user_id` where a member elsewhere has a `user`). Handed to
/// `register_guild_channels` inside the guild's own payload, where it is looked for first, so that a guild whose
/// payload leaves the account out costs no request of its own - one request a guild, at every fresh login, was the
/// alternative.
pub(super) fn guild_with_own_member(guild: &Value, merged: Option<&Value>, user_id: &str) -> Option<Value> {
let has = |list: &Value| list.as_array().is_some_and(|l| l.iter().any(|m| m["user"]["id"].as_str() == Some(user_id)));
if has(&guild["members"]) {
return None;
}
let mut me = merged?
.as_array()?
.iter()
.find(|m| m["user_id"].as_str() == Some(user_id) || m["user"]["id"].as_str() == Some(user_id))?
.clone();
if me["user"]["id"].is_null() {
me["user"] = json!({ "id": user_id });
}
let mut guild = guild.clone();
match guild["members"].as_array_mut() {
Some(list) => list.push(me),
None => guild["members"] = json!([me]),
}
Some(guild)
}
/// This account's own membership of a guild.
///
/// From the payload when it is there, and asked for when it is not. The
@@ -257,7 +295,7 @@ pub(super) async fn own_member(config: &DiscordAccountConfig, guild: &Value) ->
let resp = http_client_for(&config.token)
.get(format!("{API_BASE}/users/@me/guilds/{guild_id}/member"))
.header("Authorization", &config.token)
.send()
.send_paced()
.await
.ok()?;
if !resp.status().is_success() {
@@ -382,6 +420,8 @@ pub(super) async fn register_guild_channels(state: &AppState, config: &DiscordAc
// the way the roles-only version could.
let me = own_member(config, guild).await;
let own_roles = member_role_ids(me.as_ref());
// The account may not have spoken here: see send_guard. Begun before any channel is registered, so they open locked.
super::send_guard::open_guild(state, config, guild);
// Kept where every path that registers a guild passes, rather than in the
@@ -539,13 +579,14 @@ pub(super) async fn register_guild_channels(state: &AppState, config: &DiscordAc
// permissions change under a channel that is already registered.
let read_only = (!can_send_in_channel(is_owner, guild_id, &roles, &member_role_ids, &config.user_id, ch)).then_some(NO_SEND_PERMISSION);
if channel_map.contains_key(channel_id) {
state.runtime.set_buffer_read_only(state, &crate::model::buffer_id(&account_id, &name), read_only);
super::send_guard::set_permission(state, &account_id, &crate::model::buffer_id(&account_id, &name), read_only);
continue;
}
let buf = state.runtime.ensure_buffer(state, &account_id, &name, "channel");
state.runtime.set_buffer_read_only(state, &buf.id, read_only);
// The guild is known before the reason is worked out: the lock is the guild's.
state.runtime.set_discord_channel(state, &buf.id, channel_id);
state.runtime.set_discord_guild(&buf.id, guild_id);
super::send_guard::set_permission(state, &account_id, &buf.id, read_only);
state.runtime.set_buffer_group(state, &buf.id, &group_id);
// Where Discord itself puts this channel. Uncategorised channels sit
@@ -692,7 +733,9 @@ pub(super) async fn register_guild_channels(state: &AppState, config: &DiscordAc
},
);
spawn_backfill(state.clone(), config.token.clone(), config.user_id.clone(), config.display_name.clone(), new_channels);
// No history is fetched for these: a channel is read when it is opened (see getBacklog), which is the only time
// anyone looks at it. Fetching the latest of every channel of every server when it is first seen was a request a
// channel, at the moment of a fresh login, for conversations nobody was about to read.
visible
}
@@ -770,7 +813,7 @@ pub async fn list_threads(state: &AppState, account_id: &str, buffer_id: &str) -
requests.push(format!("{API_BASE}/channels/{channel_id}/threads/archived/public?limit=25"));
for url in requests {
let resp = match http_client_for(&cfg.token).get(&url).header("Authorization", &cfg.token).send().await {
let resp = match http_client_for(&cfg.token).get(&url).header("Authorization", &cfg.token).send_paced().await {
Ok(resp) if resp.status().is_success() => resp,
// A channel with no archive, or one this account may not read the
// archive of, is not a failure worth refusing the whole list for.
@@ -941,7 +984,7 @@ pub async fn moderate_member(
if let Some(reason) = reason.filter(|r| !r.trim().is_empty()) {
request = request.header("X-Audit-Log-Reason", reason);
}
let resp = request.send().await.context(doing)?;
let resp = request.send_paced().await.context(doing)?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
@@ -965,7 +1008,7 @@ pub async fn set_member_role(
let http = http_client_for(&cfg.token);
let request = if give { http.put(url).json(&json!({})) } else { http.delete(url) };
let doing = if give { "giving them the role" } else { "taking the role away" };
let resp = request.header("Authorization", &cfg.token).send().await.context(doing)?;
let resp = request.header("Authorization", &cfg.token).send_paced().await.context(doing)?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
@@ -999,7 +1042,7 @@ pub async fn open_thread(state: &AppState, account_id: &str, buffer_id: &str, th
let resp = http
.get(format!("{API_BASE}/channels/{thread_id}"))
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("opening the thread")?;
if !resp.status().is_success() {
@@ -1217,3 +1260,56 @@ 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);
}
}
#[cfg(test)]
mod merged_member_tests {
use super::*;
#[test]
fn a_guild_that_leaves_the_account_out_is_given_the_membership_ready_listed() {
let guild = json!({ "id": "g", "members": [{ "user": { "id": "other" } }] });
let merged = json!([{ "user_id": "me", "roles": ["r1"], "pending": true }, { "user_id": "x" }]);
let with = guild_with_own_member(&guild, Some(&merged), "me").expect("added");
let mine = with["members"].as_array().unwrap().iter().find(|m| m["user"]["id"] == "me").expect("the member is there");
assert_eq!(mine["roles"], json!(["r1"]));
assert!(member_is_pending(Some(mine)), "what the account's membership says survives");
assert_eq!(with["members"].as_array().unwrap().len(), 2, "the others stay");
// And it is what `own_member` would find first, without a request.
assert!(guild_with_own_member(&with, Some(&merged), "me").is_none());
}
#[test]
fn nothing_is_made_up_when_there_is_nothing_to_add() {
let guild = json!({ "id": "g" });
assert!(guild_with_own_member(&guild, None, "me").is_none());
assert!(guild_with_own_member(&guild, Some(&json!([{ "user_id": "someone-else" }])), "me").is_none());
let with = guild_with_own_member(&guild, Some(&json!([{ "user_id": "me" }])), "me").unwrap();
assert_eq!(with["members"][0]["user"]["id"], "me");
}
}
+95 -28
View File
@@ -28,26 +28,20 @@ impl Drop for HistoryFetch<'_> {
}
}
/// Fires off history backfill for a batch of newly-registered buffers as a
/// background task, sequentially with a small delay between requests - a
/// guild can easily have 30+ channels (confirmed live), and firing that
/// many REST requests at once risks Discord's rate limiter; a one-time
/// startup cost taking a few extra seconds in the background is a fine
/// trade for not tripping it.
pub(super) fn spawn_backfill(state: AppState, token: String, user_id: String, display_name: Option<String>, targets: Vec<(String, String)>) {
if targets.is_empty() {
return;
}
tokio::spawn(async move {
for (buffer_id, channel_id) in targets {
backfill_channel_history(&state, &token, &user_id, display_name.as_deref(), &buffer_id, &channel_id).await;
tokio::time::sleep(Duration::from_millis(250)).await;
}
});
/// How many messages a conversation is first opened with. Few: the rest is a scroll away (`extend_history`), and a
/// client that asks for fifty of every conversation it is shown is asking for what nobody reads.
const FIRST_PAGE: &str = "30";
/// The time a message was sent, from its id: a snowflake's top bits are milliseconds since Discord's epoch. How a
/// conversation's last activity is known without fetching anything of it.
pub(super) fn snowflake_secs(id: &str) -> Option<i64> {
const DISCORD_EPOCH_MS: u64 = 1_420_070_400_000;
let id: u64 = id.parse().ok()?;
Some(((id >> 22) + DISCORD_EPOCH_MS) as i64 / 1000)
}
/// Backfills up to 50 recent messages for a channel/DM with no scrollback
/// yet. Unlike IRC (which has no server-side history at all - nobilis only
/// Backfills the most recent messages for a channel/DM with no scrollback
/// yet - when it is first opened, and not before. Unlike IRC (which has no server-side history at all - nobilis only
/// ever knows what it's personally seen live), Discord actually retains
/// and exposes history, so starting every buffer blank here would look
/// broken compared to Discord's own client. Seed-once, not sync: a buffer
@@ -61,7 +55,7 @@ pub(super) fn spawn_backfill(state: AppState, token: String, user_id: String, di
/// "notification" event, neither of which should fire for messages from
/// potentially weeks ago just because this is the first time nobilis has
/// seen the channel.
pub(super) async fn backfill_channel_history(state: &AppState, token: &str, user_id: &str, own_display_name: Option<&str>, buffer_id: &str, channel_id: &str) {
pub async fn backfill_channel_history(state: &AppState, token: &str, user_id: &str, own_display_name: Option<&str>, buffer_id: &str, channel_id: &str) {
match state.store.has_messages(buffer_id) {
Ok(true) => return,
Ok(false) => {}
@@ -73,9 +67,9 @@ pub(super) async fn backfill_channel_history(state: &AppState, token: &str, user
let resp = match http_client_for(token)
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", "50")])
.query(&[("limit", FIRST_PAGE)])
.header("Authorization", token)
.send()
.send_paced()
.await
{
Ok(r) => r,
@@ -127,7 +121,7 @@ pub async fn refill_history(state: &AppState, buffer_id: &str, limit: u32) -> Re
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", limit.clamp(1, 100).to_string().as_str())])
.header("Authorization", &config.token)
.send()
.send_paced()
.await
.context("re-reading history")?;
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
@@ -164,12 +158,36 @@ pub async fn refill_history(state: &AppState, buffer_id: &str, limit: u32) -> Re
Ok(missing.len())
}
/// Asks every open conversation what it missed, one at a time.
/// Notes what a channel object says its newest message is: `None` for a
/// conversation with no messages, nothing at all when it does not say.
pub(super) fn note_newest(into: &mut HashMap<String, Option<String>>, channel: &Value) {
let (Some(id), Some(last)) = (channel["id"].as_str(), channel.get("last_message_id")) else { return };
into.insert(id.to_string(), last.as_str().map(String::from));
}
/// Whether Discord's newest message is newer than the newest stored. Ids are
/// snowflakes, which grow with time; one that will not parse is taken as
/// newer, since asking is the safe answer.
fn has_newer(theirs: Option<&str>, ours: Option<&str>) -> bool {
let Some(theirs) = theirs else { return false };
let Some(ours) = ours else { return true };
match (theirs.parse::<u64>(), ours.parse::<u64>()) {
(Ok(t), Ok(o)) => t > o,
_ => true,
}
}
/// Asks the open conversations that moved on what they missed, one at a time.
///
/// Spaced out deliberately: this runs right after connecting, alongside
/// everything else a fresh connection does, and a burst of requests is the
/// quickest way to be rate-limited by a service that has just let us in.
pub(super) fn spawn_dm_catch_up(state: AppState, config: DiscordAccountConfig, channels: HashMap<String, (String, String)>) {
pub(super) fn spawn_dm_catch_up(
state: AppState,
config: DiscordAccountConfig,
channels: HashMap<String, (String, String)>,
newest_in: HashMap<String, Option<String>>,
) {
tokio::spawn(async move {
let account_id = config.account_id();
let dms: Vec<(String, String)> = channels
@@ -177,6 +195,14 @@ pub(super) fn spawn_dm_catch_up(state: AppState, config: DiscordAccountConfig, c
.filter(|(_, (_, kind))| kind == "dm")
.filter_map(|(channel_id, (name, _))| {
let buffer = state.runtime.list_buffers().into_iter().find(|b| b.account_id == account_id && &b.name == name)?;
// Discord's word for the newest message, when it gave one:
// nothing newer than what is stored means nothing missed.
if let Some(theirs) = newest_in.get(channel_id) {
let ours = state.store.newest_msg_id(&buffer.id).ok().flatten();
if !has_newer(theirs.as_deref(), ours.as_deref()) {
return None;
}
}
Some((buffer.id, channel_id.clone()))
})
.collect();
@@ -218,7 +244,7 @@ pub async fn catch_up_channel(
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", "50"), ("after", after_id.as_str())])
.header("Authorization", token)
.send()
.send_paced()
.await
.context("fetching missed messages")?;
if resp.status().is_success() {
@@ -246,7 +272,7 @@ pub async fn extend_history(state: &AppState, token: &str, user_id: &str, own_di
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", "50"), ("before", before_id.as_str())])
.header("Authorization", token)
.send()
.send_paced()
.await
.context("fetching more history")?;
if resp.status().is_success() {
@@ -279,7 +305,7 @@ pub async fn load_newer(state: &AppState, account_id: &str, buffer_id: &str, mes
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", "50"), ("after", message_id)])
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("reading the rest of the channel")?;
if !resp.status().is_success() {
@@ -299,7 +325,7 @@ pub async fn load_context(state: &AppState, account_id: &str, buffer_id: &str, m
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", "50"), ("around", message_id)])
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("reading that part of the channel")?;
if !resp.status().is_success() {
@@ -318,3 +344,44 @@ pub async fn load_context(state: &AppState, account_id: &str, buffer_id: &str, m
.map(|dt| dt.timestamp())
.context("Discord did not return that message")
}
#[cfg(test)]
mod catch_up_tests {
use super::*;
use serde_json::json;
/// Discord's own documentation's example: 175928847299117063 was made at 2016-04-30 11:18:25.796 UTC.
#[test]
fn the_time_of_a_message_is_read_from_its_id() {
assert_eq!(snowflake_secs("175928847299117063"), Some(1_462_015_105));
assert_eq!(snowflake_secs("not a number"), None);
// What `search::snowflake_at` makes of a time reads back as that time.
assert_eq!(snowflake_secs(&super::search::snowflake_at(1_700_000_000)), Some(1_700_000_000));
}
#[test]
fn a_conversation_is_asked_about_only_when_discord_has_a_newer_message() {
assert!(has_newer(Some("200"), Some("100")));
assert!(!has_newer(Some("100"), Some("100")));
assert!(!has_newer(Some("99"), Some("100")));
// Nothing stored, but something said: a first sight is the backfill's, yet asking is harmless.
assert!(has_newer(Some("5"), None));
// An empty conversation has nothing to miss.
assert!(!has_newer(None, Some("100")));
// Ids that are not numbers are not compared.
assert!(has_newer(Some("abc"), Some("100")));
// Snowflakes outgrow 32 bits.
assert!(has_newer(Some("1500000000000000001"), Some("1500000000000000000")));
}
#[test]
fn what_a_channel_says_of_its_newest_message_is_noted_as_it_says_it() {
let mut into = HashMap::new();
note_newest(&mut into, &json!({ "id": "1", "last_message_id": "10" }));
note_newest(&mut into, &json!({ "id": "2", "last_message_id": null }));
note_newest(&mut into, &json!({ "id": "3" }));
assert_eq!(into.get("1"), Some(&Some("10".to_string())));
assert_eq!(into.get("2"), Some(&None));
assert!(!into.contains_key("3"));
}
}
+125 -6
View File
@@ -54,21 +54,98 @@ async fn pace() {
}
}
/// Where every read waits its turn: when Discord has said how long to hold off - a 429, or a bucket that ran out - no
/// request of any kind goes out before then.
static HOLD_UNTIL: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
/// What is added to a bucket's reset time, as in arikawa's limiter: Discord's own clock and ours do not agree to the
/// millisecond, and arriving on the boundary is arriving early.
const BUCKET_SLACK: Duration = Duration::from_millis(250);
/// How many times a read that was rate limited is made again, waiting as long as it was told to.
const READ_RETRIES: usize = 2;
fn hold_for(wait: Duration) {
let until = std::time::Instant::now() + wait;
let mut held = HOLD_UNTIL.lock().unwrap_or_else(|e| e.into_inner());
if held.is_none_or(|at| at < until) {
*held = Some(until);
}
}
async fn wait_out_hold() {
loop {
let wait = {
let held = HOLD_UNTIL.lock().unwrap_or_else(|e| e.into_inner());
held.map(|at| at.saturating_duration_since(std::time::Instant::now())).unwrap_or_default()
};
if wait.is_zero() {
return;
}
tokio::time::sleep(wait).await;
}
}
/// Notes what a response says about the bucket it counted against: with none left, nothing goes out until it resets.
fn note_bucket(resp: &reqwest::Response) {
let header = |name: &str| resp.headers().get(name).and_then(|v| v.to_str().ok());
if header("x-ratelimit-remaining") == Some("0") {
if let Some(reset) = header("x-ratelimit-reset-after").and_then(|v| v.parse::<f64>().ok()) {
hold_for(Duration::from_secs_f64(reset.clamp(0.0, 30.0)) + BUCKET_SLACK);
}
}
}
/// Sending that behaves as a client of an API with rate limits should: waits out any hold Discord has put on this
/// client, notes how much of a bucket is left and waits for it to refill rather than running into the limit, and when
/// it is told to slow down anyway waits as long as it was told and asks again - instead of reading a refusal as an
/// answer. Reads were sent plain, so a 429 was a silent "nothing found" and the next request followed straight on.
pub(super) trait SendPaced {
fn send_paced(self) -> impl std::future::Future<Output = reqwest::Result<reqwest::Response>> + Send;
}
impl SendPaced for reqwest::RequestBuilder {
async fn send_paced(self) -> reqwest::Result<reqwest::Response> {
wait_out_hold().await;
let mut pending = Some(self);
for attempt in 0..=READ_RETRIES {
let Some(current) = pending.take() else { break };
// A body that cannot be copied is sent once.
let again = current.try_clone();
let resp = current.send().await?;
note_bucket(&resp);
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS || attempt == READ_RETRIES {
return Ok(resp);
}
let Some(again) = again else { return Ok(resp) };
let pause = retry_after(&resp);
tracing::debug!("discord: rate limited on a read, waiting {}ms", pause.as_millis());
hold_for(pause);
wait_out_hold().await;
pending = Some(again);
}
unreachable!("the last attempt returns")
}
}
/// Like `send_write`, for a request whose body cannot be copied - a file
/// streamed from disk - and so is built again for every try.
pub(super) async fn send_write_rebuilt<F>(mut build: F) -> Result<reqwest::Response>
where
F: FnMut() -> Result<reqwest::RequestBuilder>,
{
wait_out_hold().await;
pace().await;
for attempt in 0..=RATE_LIMIT_RETRIES {
let resp = build()?.send().await.context("talking to Discord")?;
note_bucket(&resp);
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS || attempt == RATE_LIMIT_RETRIES {
return Ok(resp);
}
let pause = retry_after(&resp);
tracing::debug!("discord: rate limited, waiting {}ms", pause.as_millis());
tokio::time::sleep(pause).await;
hold_for(pause);
wait_out_hold().await;
}
bail!("Discord kept rate-limiting that request")
}
@@ -85,6 +162,7 @@ where
/// history quickly would be a slow client, and what Discord judges an account
/// on is what it sends.
pub(super) async fn send_write(request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
wait_out_hold().await;
pace().await;
let mut pending = Some(request);
@@ -94,13 +172,15 @@ pub(super) async fn send_write(request: reqwest::RequestBuilder) -> Result<reqwe
// that cannot be cloned is one this cannot retry, and is sent once.
let again = current.try_clone();
let resp = current.send().await.context("talking to Discord")?;
note_bucket(&resp);
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS || attempt == RATE_LIMIT_RETRIES {
return Ok(resp);
}
let Some(again) = again else { return Ok(resp) };
let pause = retry_after(&resp);
tracing::debug!("discord: rate limited, waiting {}ms", pause.as_millis());
tokio::time::sleep(pause).await;
hold_for(pause);
wait_out_hold().await;
pending = Some(again);
}
bail!("Discord kept rate-limiting that request")
@@ -125,7 +205,7 @@ fn pause_for(seconds: Option<&str>) -> Duration {
/// The client for requests made under `key` - the account's token, or its
/// id - routed through Tor or the configured proxy when that account is.
pub(super) fn http_client_for(key: &str) -> reqwest::Client {
discord_client(crate::net::route::router().routed(key))
discord_client(crate::net::route::router().routed(key), true)
}
/// The client for requests that belong to no account yet - the sign-in - or
@@ -133,14 +213,17 @@ pub(super) fn http_client_for(key: &str) -> reqwest::Client {
/// when the add form's switch says so, or when any Discord account is.
pub(super) fn anonymous_client() -> reqwest::Client {
let router = crate::net::route::router();
discord_client(router.routed(&crate::net::route::pending_key("discord")) || router.wanted(SHARED_KEY))
discord_client(router.routed(&crate::net::route::pending_key("discord")) || router.wanted(SHARED_KEY), false)
}
/// Marked while any Discord account is routed.
pub(super) const SHARED_KEY: &str = "discord:any-routed";
fn discord_client(routed: bool) -> reqwest::Client {
crate::net::route::router().client_if("discord", routed, |builder| {
/// `api` clients say which install, language and timezone they are (see `install_headers`); the others - the CDN,
/// the sign-in before there is an account - do not, since nothing there asks.
fn discord_client(routed: bool, api: bool) -> reqwest::Client {
crate::net::route::router().client_if(if api { "discord-api" } else { "discord" }, routed, |builder| {
let builder = if api { builder.default_headers(super::install_headers::headers().clone()) } else { builder };
// Pinned to HTTP/1.1. Enabling reqwest's http2 feature (which the
// file-upload path needs - see upload::http_client) would otherwise
// let every client here negotiate h2 as a side effect, changing the
@@ -282,4 +365,40 @@ mod error_tests {
let text = discord_error_text(reqwest::StatusCode::BAD_GATEWAY, junk, "adding a friend");
assert!(text.contains("502"), "got {text}");
}
/// What a client of an API with rate limits does: told to slow down, it waits as long as it was told and asks
/// again; told a bucket is empty, it lets it refill before anything else goes out. Asked of a local listener.
#[tokio::test]
async fn a_read_waits_out_a_rate_limit_and_a_bucket_that_has_run_dry() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
// 1: refused with a one-second wait. 2: answered, saying the bucket is empty for one second. 3: answered.
let server = tokio::spawn(async move {
let mut seen = Vec::new();
for reply in [
"HTTP/1.1 429 Too Many Requests\r\nRetry-After: 1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
"HTTP/1.1 200 OK\r\nX-RateLimit-Remaining: 0\r\nX-RateLimit-Reset-After: 1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
] {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let _ = socket.read(&mut buf).await.unwrap();
seen.push(std::time::Instant::now());
socket.write_all(reply.as_bytes()).await.unwrap();
}
seen
});
let client = reqwest::Client::new();
let started = std::time::Instant::now();
let first = client.get(&url).send_paced().await.unwrap();
assert_eq!(first.status(), 200, "the refusal was waited out and the read made again");
assert!(started.elapsed() >= Duration::from_millis(950), "it waited the second it was told to: {:?}", started.elapsed());
let second_started = std::time::Instant::now();
let second = client.get(&url).send_paced().await.unwrap();
assert_eq!(second.status(), 200);
assert!(second_started.elapsed() >= Duration::from_millis(950), "the empty bucket was let refill first: {:?}", second_started.elapsed());
let seen = server.await.unwrap();
assert_eq!(seen.len(), 3);
}
}
+179
View File
@@ -0,0 +1,179 @@
//! How often an account may log in to Discord afresh.
//!
//! A Discord client keeps one session open for days and, when the connection drops, *resumes* it. A fresh login (an
//! IDENTIFY) is what happens when a client starts or its session is lost, and a client that does it many times a day
//! looks like automation to Discord's spam detection. Restarting moho is exactly that, so the number of fresh logins an
//! account makes is kept - across restarts, in a file - and when it is over the limit the connection is held until it
//! is under again, with the window told why. A resume costs nothing and is never held.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
/// Fresh logins an account may make in any hour.
pub const PER_HOUR: usize = 3;
/// And in any day.
pub const PER_DAY: usize = 8;
const HOUR: i64 = 3600;
const DAY: i64 = 24 * HOUR;
/// Whether one more fresh login may be made at `now` (Unix seconds), given the times of the earlier ones. Answers
/// the number of seconds to wait when not. The list is kept as it should be saved: only what still counts.
pub fn admit(history: &mut Vec<i64>, now: i64) -> Result<(), i64> {
history.retain(|t| now - t < DAY);
history.sort_unstable();
let in_hour: Vec<i64> = history.iter().copied().filter(|t| now - t < HOUR).collect();
let mut wait = 0;
// Over the hour's limit: when the one that makes room ages out.
if in_hour.len() >= PER_HOUR {
wait = wait.max(in_hour[in_hour.len() - PER_HOUR] + HOUR - now);
}
// Over the day's.
if history.len() >= PER_DAY {
wait = wait.max(history[history.len() - PER_DAY] + DAY - now);
}
if wait > 0 {
return Err(wait.max(1));
}
history.push(now);
Ok(())
}
#[derive(Default, Serialize, Deserialize)]
struct Saved {
/// Account id -> when its fresh logins were made.
accounts: HashMap<String, Vec<i64>>,
}
fn file() -> std::path::PathBuf {
crate::default_data_dir().join("discord-logins.json")
}
fn held() -> &'static Mutex<Option<Saved>> {
static H: Mutex<Option<Saved>> = Mutex::new(None);
&H
}
/// Asks to make a fresh login now. `Err` carries how many seconds to wait first; `Ok` means it has been counted.
pub fn ask(account_id: &str) -> Result<(), i64> {
let now = chrono::Utc::now().timestamp();
let mut slot = held().lock().unwrap_or_else(|e| e.into_inner());
let saved = slot.get_or_insert_with(|| std::fs::read_to_string(file()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default());
let history = saved.accounts.entry(account_id.to_string()).or_default();
let answer = admit(history, now);
if answer.is_ok() {
if let Ok(text) = serde_json::to_string(saved) {
if let Some(dir) = file().parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(file(), text);
}
}
answer
}
/// The accounts whose login is being held now, each with the way to let it through.
type Held = Mutex<HashMap<String, std::sync::Arc<tokio::sync::Notify>>>;
fn waiting() -> &'static Held {
static W: std::sync::OnceLock<Held> = std::sync::OnceLock::new();
W.get_or_init(Default::default)
}
/// Marks an account as being held, answering what to wait on besides the clock.
pub fn begin_hold(account_id: &str) -> std::sync::Arc<tokio::sync::Notify> {
waiting().lock().unwrap_or_else(|e| e.into_inner()).entry(account_id.to_string()).or_default().clone()
}
/// The hold is over, by the clock or by the person.
pub fn end_hold(account_id: &str) {
waiting().lock().unwrap_or_else(|e| e.into_inner()).remove(account_id);
}
/// Lets a held login through now. `false` when nothing was being held, so that pressing the button twice, or after the
/// hold ended, cannot leave a pass waiting for some later hold.
pub fn connect_anyway(account_id: &str) -> bool {
match waiting().lock().unwrap_or_else(|e| e.into_inner()).get(account_id) {
Some(note) => {
note.notify_one();
true
}
None => false,
}
}
/// Counts a login that was let through past the limit: it still counts towards the next ones.
pub fn record(account_id: &str) {
let now = chrono::Utc::now().timestamp();
let mut slot = held().lock().unwrap_or_else(|e| e.into_inner());
let saved = slot.get_or_insert_with(|| std::fs::read_to_string(file()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default());
let history = saved.accounts.entry(account_id.to_string()).or_default();
history.retain(|t| now - t < DAY);
history.push(now);
if let Ok(text) = serde_json::to_string(saved) {
let _ = std::fs::write(file(), text);
}
}
/// What the window says while a login is held.
pub fn explain(wait: i64) -> String {
let at = chrono::Local::now() + chrono::Duration::seconds(wait);
format!(
"Holding the Discord connection: this account has already logged in {PER_HOUR} times in the last hour (or {PER_DAY} in a day), and many fresh logins can get an account flagged as spam. It will connect at {}.",
at.format("%H:%M")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_few_logins_an_hour_are_let_through_and_the_next_waits_for_the_oldest_to_age_out() {
let mut history = Vec::new();
let start = 1_000_000;
assert_eq!(admit(&mut history, start), Ok(()));
assert_eq!(admit(&mut history, start + 60), Ok(()));
assert_eq!(admit(&mut history, start + 120), Ok(()));
// The fourth, ten minutes in: held until the first is an hour old.
assert_eq!(admit(&mut history, start + 600), Err(HOUR - 600));
assert_eq!(history.len(), 3, "a held login is not counted");
// An hour after the first, there is room.
assert_eq!(admit(&mut history, start + HOUR), Ok(()));
}
#[test]
fn the_day_has_its_own_limit_however_the_hours_fall() {
let mut history = Vec::new();
let start = 5_000_000;
// Three an hour for three hours is nine; the eighth is held until the first is a day old.
let mut made = 0;
for hour in 0..3 {
for n in 0..3 {
if admit(&mut history, start + hour * HOUR + n).is_ok() {
made += 1;
}
}
}
assert_eq!(made, PER_DAY);
let wait = admit(&mut history, start + 3 * HOUR).unwrap_err();
assert_eq!(wait, DAY - 3 * HOUR);
}
#[test]
fn a_pass_is_only_given_to_a_login_that_is_being_held() {
assert!(!connect_anyway("discord:test-never-held"), "nothing is held, so nothing is let through");
let _pass = begin_hold("discord:test-held");
assert!(connect_anyway("discord:test-held"));
end_hold("discord:test-held");
assert!(!connect_anyway("discord:test-held"), "after the hold ends there is nothing to let through");
}
#[test]
fn old_logins_are_forgotten() {
let mut history = vec![1, 2, 3, 4, 5];
assert_eq!(admit(&mut history, 10 * DAY), Ok(()));
assert_eq!(history, vec![10 * DAY]);
}
}
+136
View File
@@ -0,0 +1,136 @@
//! What a Discord client tells the API about the install it runs on: which install, in what language, in what
//! timezone.
//!
//! Discord's own clients send these with every request (`X-Installation-ID`, `X-Discord-Locale`,
//! `X-Discord-Timezone`), so an API that sees a long-lived account from one install, in one place, with one
//! language, sees what it expects. They say nothing about *what* is connecting - that stays `moho/<version>
//! (nobilis)` - and the install id is moho's own, made here once and kept: it is not a copy of anything Discord
//! issued.
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use std::sync::OnceLock;
/// The id of this install: random, made the first time it is needed, and kept in the data directory so that every
/// run - and every account on it - is the same install.
pub fn installation_id() -> String {
let file = crate::default_data_dir().join("discord-installation-id");
if let Some(id) = std::fs::read_to_string(&file).ok().map(|t| t.trim().to_string()).filter(|t| well_formed(t)) {
return id;
}
let bytes: [u8; 16] = rand::random();
let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
if let Some(dir) = file.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&file, &id);
id
}
fn well_formed(id: &str) -> bool {
id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
}
/// The language this machine is set to, as Discord writes it (`en-US`): from the environment's usual variables, in
/// their usual order of precedence. `None` when it says nothing, or says "C".
pub fn locale() -> Option<String> {
let raw = ["LC_ALL", "LC_MESSAGES", "LANG"].iter().filter_map(|v| std::env::var(v).ok()).find(|v| !v.is_empty())?;
locale_from(&raw)
}
fn locale_from(raw: &str) -> Option<String> {
// `en_US.UTF-8@euro` -> `en-US`
let tag = raw.split(['.', '@']).next()?.replace('_', "-");
let mut parts = tag.split('-');
let language = parts.next().filter(|l| (2..=3).contains(&l.len()) && l.bytes().all(|b| b.is_ascii_alphabetic()))?;
if language.eq_ignore_ascii_case("C") || language.eq_ignore_ascii_case("POSIX") {
return None;
}
let language = language.to_ascii_lowercase();
match parts.next().filter(|r| r.len() == 2 && r.bytes().all(|b| b.is_ascii_alphabetic())) {
Some(region) => Some(format!("{language}-{}", region.to_ascii_uppercase())),
None => Some(language),
}
}
/// The IANA name of this machine's timezone (`America/New_York`), when it can be told.
pub fn timezone() -> Option<String> {
iana_time_zone::get_timezone().ok().filter(|t| !t.is_empty())
}
/// The headers every API request carries.
pub fn headers() -> &'static HeaderMap {
static HEADERS: OnceLock<HeaderMap> = OnceLock::new();
HEADERS.get_or_init(|| {
let mut pairs: Vec<(&'static str, String)> = vec![("x-installation-id", installation_id())];
if let Some(locale) = locale() {
let language = locale.split('-').next().unwrap_or("en").to_string();
pairs.push(("accept-language", format!("{locale},{language};q=0.9")));
pairs.push(("x-discord-locale", locale));
}
if let Some(zone) = timezone() {
pairs.push(("x-discord-timezone", zone));
}
let mut map = HeaderMap::new();
for (name, value) in pairs {
if let Ok(value) = HeaderValue::from_str(&value) {
map.insert(HeaderName::from_static(name), value);
}
}
map
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_locale_is_written_the_way_discord_writes_it() {
assert_eq!(locale_from("en_US.UTF-8").as_deref(), Some("en-US"));
assert_eq!(locale_from("de_DE@euro").as_deref(), Some("de-DE"));
assert_eq!(locale_from("fr").as_deref(), Some("fr"));
assert_eq!(locale_from("pt-br").as_deref(), Some("pt-BR"));
}
#[test]
fn a_machine_with_no_language_set_sends_none() {
assert_eq!(locale_from("C"), None);
assert_eq!(locale_from("POSIX"), None);
assert_eq!(locale_from("C.UTF-8"), None);
assert_eq!(locale_from(""), None);
assert_eq!(locale_from("12345"), None);
}
#[test]
fn an_installation_id_is_recognised_only_when_whole() {
assert!(well_formed("0123456789abcdef0123456789abcdef"));
assert!(!well_formed("0123"));
assert!(!well_formed("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"));
}
/// What actually leaves: the API client carries the headers and still says it is moho, and the client for the CDN
/// and sign-in carries none of them. Asked of a local listener, so nothing reaches Discord.
#[tokio::test]
async fn the_api_client_sends_them_and_the_other_does_not() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn request_seen_by_a_listener(client: reqwest::Client) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let n = socket.read(&mut buf).await.unwrap();
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap();
String::from_utf8_lossy(&buf[..n]).to_lowercase()
});
let _ = client.get(url).send().await;
server.await.unwrap()
}
let api = request_seen_by_a_listener(crate::backend::discord::http::http_client_for("a-test-token")).await;
assert!(api.contains("x-installation-id: "), "{api}");
assert!(api.contains("user-agent: moho/"), "{api}");
assert!(!api.contains("x-super-properties"), "{api}");
let other = request_seen_by_a_listener(crate::backend::discord::http::anonymous_client()).await;
assert!(!other.contains("x-installation-id"), "{other}");
}
}
+2 -2
View File
@@ -249,7 +249,7 @@ pub(super) async fn run_resign(state: &AppState, buffer_id: &str, mut stale: Vec
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", "50"), ("around", anchor.as_str())])
.header("Authorization", &config.token)
.send()
.send_paced()
.await
.context("fetching a page to re-sign")?;
if !resp.status().is_success() {
@@ -358,7 +358,7 @@ pub async fn refresh_attachments(state: &AppState, buffer_id: &str, message_id:
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", "1"), ("around", message_id)])
.header("Authorization", &config.token)
.send()
.send_paced()
.await
.context("re-fetching the message")?;
if !resp.status().is_success() {
+312 -64
View File
@@ -23,7 +23,7 @@ pub async fn list_pinned(state: &AppState, account_id: &str, buffer_id: &str) ->
let resp = http_client_for(&cfg.token)
.get(format!("{API_BASE}/channels/{channel_id}/pins"))
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("reading the pins")?;
if !resp.status().is_success() {
@@ -96,7 +96,7 @@ pub async fn set_pinned(state: &AppState, account_id: &str, buffer_id: &str, mes
let http = http_client_for(&cfg.token);
let request = if pinned { http.put(url) } else { http.delete(url) };
let doing = if pinned { "pinning the message" } else { "unpinning the message" };
let resp = request.header("Authorization", &cfg.token).send().await.context(doing)?;
let resp = request.header("Authorization", &cfg.token).send_paced().await.context(doing)?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
@@ -601,7 +601,7 @@ pub async fn name_forward(state: &AppState, account_id: &str, buffer_id: &str, m
let resp = http_client_for(&cfg.token)
.get(format!("{API_BASE}/channels/{source_channel}/messages?limit=1&around={source_message}"))
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await;
// A source this account cannot see is the ordinary case for a forward
// out of somebody else's server, and not worth a word to anybody.
@@ -703,6 +703,12 @@ pub(super) fn extract_attachments(d: &Value) -> Vec<Attachment> {
/// represented by extract_body's own media-URL handling above, so
/// including it here too would just render an empty box under the media.
pub(super) fn extract_embeds(d: &Value) -> Vec<Embed> {
let mut out = ordinary_embeds(d);
out.extend(layout_cards(d));
out
}
fn ordinary_embeds(d: &Value) -> Vec<Embed> {
let Some(embeds) = d["embeds"].as_array() else { return Vec::new() };
embeds
.iter()
@@ -910,6 +916,7 @@ pub(super) fn extract_reply(d: &Value) -> Option<ReplyPreview> {
thread: false,
forwarded: true,
thread_root: None,
chat: None,
});
}
let referenced = &d["referenced_message"];
@@ -921,7 +928,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 })
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false, thread_root: None, chat: None })
}
/// Shared by both the initial backfill and extend_history - Discord
@@ -957,9 +964,17 @@ pub(super) fn store_history_messages(state: &AppState, buffer_id: &str, messages
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp())
.unwrap_or(0);
if let Err(e) = state.store.append_message(buffer_id, msg_id, from, &body, ts, false, false, "chat", reply_to.as_ref(), &reactions, is_own, avatar_url.as_deref(), &embeds, &attachments, author["id"].as_str(), None, &state.runtime.buffer_kind_of(buffer_id), None, &[]) {
tracing::warn!("discord: storing history message: {e}");
continue;
let badges = author_badges(author);
match state.store.append_message(buffer_id, msg_id, from, &body, ts, false, false, "chat", reply_to.as_ref(), &reactions, is_own, avatar_url.as_deref(), &embeds, &attachments, author["id"].as_str(), None, &state.runtime.buffer_kind_of(buffer_id), None, &badges) {
Err(e) => {
tracing::warn!("discord: storing history message: {e}");
continue;
}
// Read again, a message kept before bots were marked gets its mark.
Ok(false) if !badges.is_empty() => {
let _ = state.store.set_badges(buffer_id, msg_id, &badges);
}
Ok(_) => {}
}
note_components(state, buffer_id, msg_id, msg);
// A poll in the history may still be running - Discord's last hours
@@ -1012,6 +1027,26 @@ pub(super) fn extract_reactions(msg: &Value) -> Vec<Reaction> {
.collect()
}
/// What Discord marks an author as, beside their name: "APP" for a bot or an application, with a check when Discord has
/// verified it. Read off the author the message carries, so it is there for history and for live messages alike.
pub(super) fn author_badges(author: &Value) -> Vec<crate::backend::kick::api::Badge> {
// Bit 16 of the public flags: a verified bot.
const VERIFIED_BOT: u64 = 1 << 16;
// Discord's own account: what it sends is the platform speaking, and says so with the OFFICIAL mark.
if author["system"].as_bool() == Some(true) {
return vec![crate::backend::kick::api::Badge { kind: "official".to_string(), text: "Official Discord Message".to_string(), ..Default::default() }];
}
if author["bot"].as_bool() != Some(true) {
return Vec::new();
}
let verified = author["public_flags"].as_u64().unwrap_or(0) & VERIFIED_BOT != 0;
vec![crate::backend::kick::api::Badge {
kind: if verified { "verified-app" } else { "app" }.to_string(),
text: if verified { "Verified App" } else { "App" }.to_string(),
..Default::default()
}]
}
/// The buttons and menus on a message, in this client's own shape.
///
/// Discord lays them out in rows of up to five; the row is kept so a client
@@ -1021,65 +1056,190 @@ pub(super) fn extract_reactions(msg: &Value) -> Vec<Reaction> {
pub(super) fn extract_components(msg: &Value) -> Vec<model::Component> {
let Some(rows) = msg["components"].as_array() else { return Vec::new() };
let mut out = Vec::new();
for (row_index, row) in rows.iter().enumerate() {
// A row is itself a component (type 1) holding the real ones. A
// message whose top level is already a control is not a shape Discord
// sends, but reading it either way costs nothing.
let children = row["components"].as_array().cloned().unwrap_or_else(|| vec![row.clone()]);
for child in children {
let kind = match child["type"].as_i64() {
Some(2) => "button",
// 3 is the plain string select; 5 through 8 are the ones that
// pick users, roles, mentionables and channels. They are all
// menus, and all of them answer the same way.
Some(3) | Some(5) | Some(6) | Some(7) | Some(8) => "select",
_ => continue,
};
let style = child["style"].as_i64().map(|s| match s {
1 => "primary",
2 => "secondary",
3 => "success",
4 => "danger",
5 => "link",
_ => "secondary",
});
let options = child["options"]
.as_array()
.map(|opts| {
opts.iter()
.filter_map(|o| {
Some(model::ComponentOption {
value: o["value"].as_str()?.to_string(),
label: o["label"].as_str().unwrap_or_default().to_string(),
description: o["description"].as_str().map(str::to_string),
})
})
.collect()
})
.unwrap_or_default();
out.push(model::Component {
kind: kind.to_string(),
custom_id: child["custom_id"].as_str().map(str::to_string),
label: child["label"].as_str().filter(|l| !l.is_empty()).map(str::to_string),
style: style.map(str::to_string),
url: child["url"].as_str().map(str::to_string),
disabled: child["disabled"].as_bool().unwrap_or(false),
// Written the way this client writes emoji everywhere else, so
// a button's picture renders like one in a message does.
emoji: match (child["emoji"]["name"].as_str(), child["emoji"]["id"].as_str()) {
(Some(name), Some(id)) => Some(format!("<:{name}:{id}>")),
(Some(name), None) => Some(name.to_string()),
_ => None,
},
options,
placeholder: child["placeholder"].as_str().map(str::to_string),
row: row_index as i64,
});
}
let mut row_index = 0i64;
for row in rows {
controls_in(row, &mut row_index, &mut out);
}
out
}
/// The buttons and menus inside one component, however deep the layout puts them.
///
/// An ordinary message has rows (type 1) of controls. A message made with Discord's newer layout components wraps
/// them: a container (17) holds rows, sections (9) - whose accessory may itself be a button - and text, and a control
/// may sit anywhere in it. Each row, and each section's accessory, is one line of controls in the order met.
fn controls_in(node: &Value, row_index: &mut i64, out: &mut Vec<model::Component>) {
match node["type"].as_i64() {
// An action row: its children are the controls.
Some(1) => {
for child in node["components"].as_array().into_iter().flatten() {
push_control(child, *row_index, out);
}
*row_index += 1;
}
// A container, or anything else that holds components: look inside.
Some(17) => {
for child in node["components"].as_array().into_iter().flatten() {
controls_in(child, row_index, out);
}
}
// A section's accessory is a thumbnail or a button; the text beside it holds no controls.
Some(9) => {
if node["accessory"]["type"].as_i64() == Some(2) {
push_control(&node["accessory"], *row_index, out);
*row_index += 1;
}
}
// A control with no row around it is not a shape Discord sends, but reading it costs nothing.
_ => push_control(node, *row_index, out),
}
}
fn push_control(child: &Value, row_index: i64, out: &mut Vec<model::Component>) {
let kind = match child["type"].as_i64() {
Some(2) => "button",
// 3 is the plain string select; 5 through 8 are the ones that
// pick users, roles, mentionables and channels. They are all
// menus, and all of them answer the same way.
Some(3) | Some(5) | Some(6) | Some(7) | Some(8) => "select",
_ => return,
};
let style = child["style"].as_i64().map(|s| match s {
1 => "primary",
2 => "secondary",
3 => "success",
4 => "danger",
5 => "link",
_ => "secondary",
});
let options = child["options"]
.as_array()
.map(|opts| {
opts.iter()
.filter_map(|o| {
Some(model::ComponentOption {
value: o["value"].as_str()?.to_string(),
label: o["label"].as_str().unwrap_or_default().to_string(),
description: o["description"].as_str().map(str::to_string),
})
})
.collect()
})
.unwrap_or_default();
out.push(model::Component {
kind: kind.to_string(),
custom_id: child["custom_id"].as_str().map(str::to_string),
label: child["label"].as_str().filter(|l| !l.is_empty()).map(str::to_string),
style: style.map(str::to_string),
url: child["url"].as_str().map(str::to_string),
disabled: child["disabled"].as_bool().unwrap_or(false),
// Written the way this client writes emoji everywhere else, so
// a button's picture renders like one in a message does.
emoji: match (child["emoji"]["name"].as_str(), child["emoji"]["id"].as_str()) {
(Some(name), Some(id)) => Some(format!("<:{name}:{id}>")),
(Some(name), None) => Some(name.to_string()),
_ => None,
},
options,
placeholder: child["placeholder"].as_str().map(str::to_string),
row: row_index,
});
}
/// What a message made with Discord's layout components says, as cards.
///
/// Those messages carry no text of their own: a container (type 17) holds text displays (10), sections (9) pairing text
/// with a picture or a button, galleries of pictures (12), files (13) and separators (14). Each container becomes one
/// card - its text as the description, a leading heading line as the title, its first picture as the card's picture and
/// its accent colour - and text or pictures outside any container become one more. The buttons in them are read apart
/// (see `controls_in`).
fn layout_cards(d: &Value) -> Vec<Embed> {
let Some(top) = d["components"].as_array() else { return Vec::new() };
let mut cards = Vec::new();
let mut loose = Card::default();
for node in top {
if node["type"].as_i64() == Some(17) {
let mut card = Card::default();
card.take(node);
if let Some(embed) = card.into_embed(node["accent_color"].as_i64()) {
cards.push(embed);
}
} else {
loose.take(node);
}
}
if let Some(embed) = loose.into_embed(None) {
cards.insert(0, embed);
}
cards
}
#[derive(Default)]
struct Card {
texts: Vec<String>,
picture: Option<String>,
}
impl Card {
fn take(&mut self, node: &Value) {
match node["type"].as_i64() {
Some(10) => {
if let Some(text) = node["content"].as_str().filter(|t| !t.trim().is_empty()) {
self.texts.push(text.to_string());
}
}
// A thumbnail, or a gallery of pictures: the first is the card's.
Some(11) => self.picture_of(&node["media"]),
Some(12) => {
for item in node["items"].as_array().into_iter().flatten() {
self.picture_of(&item["media"]);
}
}
Some(13) => {
if let Some(url) = node["file"]["url"].as_str() {
self.texts.push(format!("[{}]({url})", node["name"].as_str().unwrap_or("Attached file")));
}
}
// A section: its text, and its accessory when that is a picture.
Some(9) | Some(17) => {
for child in node["components"].as_array().into_iter().flatten() {
self.take(child);
}
if node["accessory"]["type"].as_i64() == Some(11) {
self.picture_of(&node["accessory"]["media"]);
}
}
_ => {}
}
}
fn picture_of(&mut self, media: &Value) {
if self.picture.is_none() {
self.picture = media["url"].as_str().map(str::to_string);
}
}
fn into_embed(self, accent: Option<i64>) -> Option<Embed> {
if self.texts.is_empty() && self.picture.is_none() {
return None;
}
let joined = self.texts.join("\n\n");
// A first line that is a markdown heading is what the card is called.
let (title, description) = match joined.split_once('\n') {
Some((first, rest)) if first.starts_with('#') => (Some(first.trim_start_matches('#').trim().to_string()), rest.trim().to_string()),
None if joined.starts_with('#') => (Some(joined.trim_start_matches('#').trim().to_string()), String::new()),
_ => (None, joined),
};
Some(Embed {
title: title.filter(|t| !t.is_empty()),
description: Some(description).filter(|d| !d.is_empty()),
color: accent,
image_url: self.picture,
..Default::default()
})
}
}
/// Stores a message's buttons and tells the client, if it has any.
///
/// Separate from the message itself because they arrive with it and are
@@ -1520,7 +1680,7 @@ pub async fn list_reactors(state: &AppState, account_id: &str, buffer_id: &str,
let resp = http_client_for(&cfg.token)
.get(url)
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("reading who reacted")?;
if !resp.status().is_success() {
@@ -1583,7 +1743,7 @@ pub async fn reread_embeds(state: &AppState, buffer_id: &str) -> Result<usize> {
.get(format!("{API_BASE}/channels/{channel_id}/messages"))
.query(&[("limit", "50")])
.header("Authorization", &config.token)
.send()
.send_paced()
.await
.context("re-reading the conversation")?;
if !resp.status().is_success() {
@@ -1725,3 +1885,91 @@ mod gif_tests {
assert_eq!(extract_body(&quiet), None);
}
}
#[cfg(test)]
mod layout_tests {
use super::{extract_components, extract_embeds};
use serde_json::json;
/// What a bot's message built with Discord's layout components looks like: no text of its own, a container with
/// a section (text and a picture) and a row holding one button.
fn honeypot() -> serde_json::Value {
json!({
"content": "",
"flags": 32768,
"components": [{
"type": 17,
"accent_color": 0x5865f2,
"components": [
{ "type": 9,
"components": [{ "type": 10, "content": "## DO NOT SEND MESSAGES IN THIS CHANNEL\nThis channel is used to catch spam bots. Any messages sent here will result in **an immediate ban**." }],
"accessory": { "type": 11, "media": { "url": "https://cdn.discordapp.com/attachments/1/2/honeypot.png" } } },
{ "type": 14, "divider": true },
{ "type": 1, "components": [{ "type": 2, "style": 2, "label": "Bans: 19", "custom_id": "bans", "disabled": true, "emoji": { "name": "🍯" } }] }
]
}]
})
}
#[test]
fn a_container_is_one_card_with_a_title_its_text_and_its_picture() {
let cards = extract_embeds(&honeypot());
assert_eq!(cards.len(), 1);
assert_eq!(cards[0].title.as_deref(), Some("DO NOT SEND MESSAGES IN THIS CHANNEL"));
assert!(cards[0].description.as_deref().unwrap().starts_with("This channel is used to catch spam bots."));
assert_eq!(cards[0].image_url.as_deref(), Some("https://cdn.discordapp.com/attachments/1/2/honeypot.png"));
assert_eq!(cards[0].color, Some(0x5865f2));
}
#[test]
fn the_buttons_inside_a_container_are_found() {
let controls = extract_components(&honeypot());
assert_eq!(controls.len(), 1);
assert_eq!((controls[0].label.as_deref(), controls[0].kind.as_str(), controls[0].disabled), (Some("Bans: 19"), "button", true));
assert_eq!(controls[0].emoji.as_deref(), Some("🍯"));
}
#[test]
fn a_sections_button_accessory_is_a_control_and_its_picture_accessory_is_not() {
let msg = json!({ "components": [{ "type": 17, "components": [
{ "type": 9, "components": [{ "type": 10, "content": "Pick" }], "accessory": { "type": 2, "style": 1, "label": "Go", "custom_id": "go" } },
] }] });
let controls = extract_components(&msg);
assert_eq!(controls.len(), 1);
assert_eq!(controls[0].label.as_deref(), Some("Go"));
assert_eq!(extract_embeds(&msg)[0].description.as_deref(), Some("Pick"));
}
#[test]
fn an_ordinary_message_has_no_layout_card() {
let msg = json!({ "content": "hi", "components": [{ "type": 1, "components": [{ "type": 2, "label": "x", "custom_id": "x", "style": 1 }] }] });
assert!(extract_embeds(&msg).is_empty());
assert_eq!(extract_components(&msg).len(), 1);
}
}
#[cfg(test)]
mod app_badge_tests {
use super::author_badges;
use serde_json::json;
#[test]
fn a_bot_is_an_app_and_a_verified_one_says_so() {
let plain = author_badges(&json!({ "id": "1", "bot": true, "public_flags": 0 }));
assert_eq!((plain[0].kind.as_str(), plain[0].text.as_str()), ("app", "App"));
let verified = author_badges(&json!({ "id": "1", "bot": true, "public_flags": 65536 }));
assert_eq!((verified[0].kind.as_str(), verified[0].text.as_str()), ("verified-app", "Verified App"));
}
#[test]
fn discords_own_account_is_official_even_though_it_is_not_an_app() {
let official = author_badges(&json!({ "id": "643945264868098049", "system": true, "bot": true }));
assert_eq!((official[0].kind.as_str(), official[0].text.as_str()), ("official", "Official Discord Message"));
}
#[test]
fn a_person_has_no_badge() {
assert!(author_badges(&json!({ "id": "2", "username": "someone" })).is_empty());
assert!(author_badges(&json!({ "id": "2", "bot": false })).is_empty());
}
}
+4
View File
@@ -49,10 +49,14 @@ pub mod voice;
pub mod calls;
pub mod commands;
pub mod gateway;
pub mod identify_budget;
pub mod dave;
pub mod golive;
pub mod guilds;
pub mod history;
pub mod install_headers;
pub mod send_guard;
pub mod session_store;
pub mod http;
pub mod login;
pub mod media;
+11 -4
View File
@@ -27,7 +27,9 @@ pub(super) fn register_dm_channel(
state.runtime.set_discord_channel(state, &buf.id, channel_id);
// Discord's own notices come from an account flagged `system`, and nothing
// sent back to it goes anywhere.
state.runtime.set_buffer_read_only(state, &buf.id, is_system_dm(ch).then_some(OFFICIAL_ONLY));
super::send_guard::set_permission(state, account_id, &buf.id, is_system_dm(ch).then_some(OFFICIAL_ONLY));
// A conversation with nothing in it yet is not started from here: see send_guard.
super::send_guard::note_dm(state, account_id, &buf.id, ch);
// Its notices were saved before their cards could be read whole, and saved
// history is not fetched again, so they are read once a run, a little after
// connecting - one request, for the latest page.
@@ -40,6 +42,11 @@ pub(super) fn register_dm_channel(
}
});
}
// When it was last spoken in, from the id of its last message - which is all that ordering the list by activity
// needs, so nothing is fetched for it.
if let Some(at) = ch["last_message_id"].as_str().and_then(super::history::snowflake_secs) {
state.runtime.raise_buffer_activity(state, &buf.id, at);
}
if let Some(avatar) = dm_avatar_url(ch) {
state.runtime.set_buffer_avatar(state, &buf.id, &avatar);
}
@@ -166,7 +173,7 @@ pub async fn profile(state: &AppState, account_id: &str, buffer_id: &str, user_i
}
}
let response = http_client_for(&config.token).get(&url).header("Authorization", &config.token).send().await;
let response = http_client_for(&config.token).get(&url).header("Authorization", &config.token).send_paced().await;
let Ok(body) = response else { return profile };
let Ok(body) = body.json::<Value>().await else { return profile };
@@ -295,7 +302,7 @@ pub async fn adopt_dm_channel(state: &AppState, account_id: &str, channel_id: &s
let resp = http_client_for(&config.token)
.get(format!("{API_BASE}/channels/{channel_id}"))
.header("Authorization", &config.token)
.send()
.send_paced()
.await
.context("asking about that conversation")?;
if !resp.status().is_success() {
@@ -379,7 +386,7 @@ pub async fn answer_friend_request(state: &AppState, account_id: &str, user_id:
let url = format!("{API_BASE}/users/@me/relationships/{user_id}");
let request = if accept { http.put(url).json(&json!({})) } else { http.delete(url) };
let doing = if accept { "accepting the friend request" } else { "declining the friend request" };
let resp = request.header("Authorization", &cfg.token).send().await.context(doing)?;
let resp = request.header("Authorization", &cfg.token).send_paced().await.context(doing)?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
+73 -1
View File
@@ -117,6 +117,46 @@ fn settings_body(status: &str, text: Option<&str>) -> serde_json::Value {
/// does when you open a channel, which is also why the roster only exists for
/// channels somebody is actually looking at.
pub fn request_member_list(state: &AppState, buffer_id: &str) -> bool {
subscribe_member_list(state, buffer_id, false)
}
/// The window the list is kept in no longer matches what Discord holds, so it is asked for again - but not more often
/// than every few seconds, which is the most a roster that keeps falling out of step deserves.
pub(super) fn resync_member_list(state: &AppState, buffer_id: &str) -> bool {
subscribe_member_list(state, buffer_id, true)
}
/// What each server has been subscribed to this session: the channel, and when.
type Subscribed = HashMap<(String, String), (String, std::time::Instant)>;
static SUBSCRIBED: std::sync::Mutex<Option<Subscribed>> = std::sync::Mutex::new(None);
/// The least time between two subscriptions to one server's list that are not a change of channel.
const RESYNC_EVERY: std::time::Duration = std::time::Duration::from_secs(10);
/// Whether a subscription to this channel's list is to be sent: not if it already was, this session, and not a
/// repeat of one sent a moment ago. A connection is subscribed once - as Dissent's is, once a server - and a new one
/// starts again (`forget_member_subscriptions`).
fn subscription_due(account: &str, guild: &str, channel: &str, resync: bool, now: std::time::Instant) -> bool {
let mut slot = SUBSCRIBED.lock().unwrap_or_else(|e| e.into_inner());
let map = slot.get_or_insert_with(HashMap::new);
let key = (account.to_string(), guild.to_string());
match map.get(&key) {
Some((was, at)) if was == channel && (!resync || now.duration_since(*at) < RESYNC_EVERY) => false,
_ => {
map.insert(key, (channel.to_string(), now));
true
}
}
}
/// A new connection holds none of what the last one subscribed to.
pub(super) fn forget_member_subscriptions(account: &str) {
if let Some(map) = SUBSCRIBED.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
map.retain(|(a, _), _| a != account);
}
}
fn subscribe_member_list(state: &AppState, buffer_id: &str, resync: bool) -> bool {
let Some(buffer) = state.runtime.get_buffer(buffer_id) else { return false };
let account_id = buffer.account_id;
let Some(sender) = state.runtime.discord_gateway_sender(&account_id) else { return false };
@@ -131,6 +171,11 @@ pub fn request_member_list(state: &AppState, buffer_id: &str) -> bool {
// channel, so remember which buffer this was for.
state.runtime.set_discord_member_list_target(&account_id, &guild_id, buffer_id);
// Asked for once. Whoever asked is told it is being sent: the roster arrives, or is already arriving.
if !subscription_due(&account_id, &guild_id, &channel_id, resync, std::time::Instant::now()) {
return true;
}
// Ranges are 100-member windows; one covers any channel we would show.
sender
.send(
@@ -370,7 +415,7 @@ pub(super) fn update_member_list(state: &AppState, buffer_id: &str, d: &Value) {
Applied::Stale => {
tracing::debug!("discord: member window for {buffer_id} is out of step; resyncing");
state.runtime.set_discord_member_window(buffer_id, Vec::new());
request_member_list(state, buffer_id);
resync_member_list(state, buffer_id);
return;
}
}
@@ -629,3 +674,30 @@ mod status_tests {
assert_eq!(settings_body("idle", None)["status"], "idle");
}
}
#[cfg(test)]
mod subscription_tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn a_list_is_subscribed_to_once_a_connection_and_again_for_another_channel() {
let t = Instant::now();
assert!(subscription_due("a1", "g", "c1", false, t));
assert!(!subscription_due("a1", "g", "c1", false, t + Duration::from_secs(1)), "the same again is nothing");
assert!(!subscription_due("a1", "g", "c1", false, t + Duration::from_secs(600)), "however long after");
assert!(subscription_due("a1", "g", "c2", false, t + Duration::from_secs(2)), "another channel is a change");
assert!(subscription_due("a1", "g2", "c1", false, t), "another server is its own");
forget_member_subscriptions("a1");
assert!(subscription_due("a1", "g", "c2", false, t), "a new connection starts again");
}
#[test]
fn a_resync_is_not_sent_again_within_a_few_seconds() {
let t = Instant::now();
assert!(subscription_due("a2", "g", "c1", false, t));
assert!(!subscription_due("a2", "g", "c1", true, t + Duration::from_secs(3)));
assert!(subscription_due("a2", "g", "c1", true, t + Duration::from_secs(11)));
assert!(!subscription_due("a2", "g", "c1", true, t + Duration::from_secs(12)));
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ pub async fn search_messages(
.get(url)
.query(&query)
.header("Authorization", &cfg.token)
.send()
.send_paced()
.await
.context("searching")?;
// Discord answers a search of a server it is still indexing with 202 and
+115 -3
View File
@@ -220,6 +220,7 @@ pub async fn forward_message(
.runtime
.get_discord_channel(to_buffer)
.ok_or_else(|| anyhow!("there is no Discord conversation to send it to"))?;
super::send_guard::ensure_may_speak(state, to_buffer)?;
let mut reference = json!({
// 1 is Discord's own number for a forward, as against 0 for a reply.
"type": 1,
@@ -235,7 +236,11 @@ pub async fn forward_message(
http_client_for(token)
.post(format!("{API_BASE}/channels/{target_channel}/messages"))
.header("Authorization", token)
.json(&json!({ "content": "", "message_reference": reference })),
.json(&{
let mut payload = json!({ "content": "", "message_reference": reference });
super::send_guard::stamp(&mut payload);
payload
}),
)
.await
.context("forwarding the message")?;
@@ -244,6 +249,7 @@ pub async fn forward_message(
let text = resp.text().await.unwrap_or_default();
bail!("{}", discord_error_text(status, &text, "forwarding that message"));
}
super::send_guard::note_sent(resp).await;
Ok(())
}
@@ -252,10 +258,15 @@ pub async fn send_message(state: &AppState, buffer_id: &str, token: &str, body:
.runtime
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
// Nothing is said anywhere the account has not spoken before: see send_guard.
super::send_guard::ensure_may_speak(state, buffer_id)?;
let account_id = state.runtime.get_buffer(buffer_id).map(|b| b.account_id).unwrap_or_default();
let content = resolve_outgoing_mentions(body, mention_candidates(state, &account_id, buffer_id));
super::send_guard::check_text(&content, state.runtime.discord_premium(&account_id), false)?;
let mut payload = json!({ "content": content });
put_reply(&mut payload, reply_to_id, reply_ping);
super::send_guard::stamp(&mut payload);
super::send_guard::take_turn(&account_id, false).await;
let resp = send_write(
http_client_for(token)
.post(format!("{API_BASE}/channels/{channel_id}/messages"))
@@ -269,6 +280,7 @@ pub async fn send_message(state: &AppState, buffer_id: &str, token: &str, body:
let text = resp.text().await.unwrap_or_default();
bail!("{}", refusal_text(status, &text));
}
super::send_guard::note_sent(resp).await;
Ok(())
}
@@ -371,6 +383,7 @@ pub async fn send_attachments(
.runtime
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
super::send_guard::ensure_may_speak(state, buffer_id)?;
if let Some(p) = progress {
p.at(Phase::Preparing, 0, Meter::HOST);
}
@@ -385,12 +398,18 @@ pub async fn send_attachments(
total += len;
files.push((path.clone(), name, len));
}
// What Discord would refuse is refused here, before any of it is sent.
super::send_guard::ensure_fits(state, buffer_id, &files)?;
// A caption is a message like any other, so a name typed in one is a
// mention like any other.
let account_id = state.runtime.get_buffer(buffer_id).map(|b| b.account_id).unwrap_or_default();
let content = resolve_outgoing_mentions(body, mention_candidates(state, &account_id, buffer_id));
super::send_guard::check_text(&content, state.runtime.discord_premium(&account_id), true)?;
let mut payload = json!({ "content": content });
put_reply(&mut payload, reply_to_id, reply_ping);
super::send_guard::stamp(&mut payload);
// A file does not follow straight on from a message.
super::send_guard::take_turn(&account_id, true).await;
if let Some(p) = progress {
p.sent(0, total, Meter::HOST);
}
@@ -420,6 +439,7 @@ pub async fn send_attachments(
let text = resp.text().await.unwrap_or_default();
bail!("{}", refusal_text(status, &text));
}
super::send_guard::note_sent(resp).await;
Ok(())
}
@@ -452,6 +472,7 @@ pub async fn send_voice_message(
.runtime
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
super::send_guard::ensure_may_speak(state, buffer_id)?;
let bytes = tokio::fs::read(file_path)
.await
.with_context(|| format!("reading {}", file_path.display()))?;
@@ -459,7 +480,7 @@ pub async fn send_voice_message(
// than on this, but a file that arrives anywhere else should say what it
// is - and an unnamed part is rejected outright.
let file_name = "voice-message.ogg";
let payload = json!({
let mut payload = json!({
"content": "",
"flags": IS_VOICE_MESSAGE,
"attachments": [{
@@ -469,6 +490,7 @@ pub async fn send_voice_message(
"waveform": waveform,
}],
});
super::send_guard::stamp(&mut payload);
let form = reqwest::multipart::Form::new()
.text("payload_json", payload.to_string())
.part(
@@ -491,6 +513,7 @@ pub async fn send_voice_message(
let text = resp.text().await.unwrap_or_default();
bail!("{}", refusal_text(status, &text));
}
super::send_guard::note_sent(resp).await;
Ok(())
}
@@ -795,6 +818,12 @@ pub async fn send_typing(state: &AppState, buffer_id: &str, token: &str) -> Resu
.runtime
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
// The window repeats its notice every few seconds for every service, and
// a Discord indicator stands for ten, so most of those would only be
// noise on the wire: one a channel per `TYPING_EVERY` is plenty.
if !typing_due(&channel_id) {
return Ok(());
}
send_write(
http_client_for(token)
.post(format!("{API_BASE}/channels/{channel_id}/typing"))
@@ -806,6 +835,24 @@ pub async fn send_typing(state: &AppState, buffer_id: &str, token: &str) -> Resu
Ok(())
}
/// Shortest gap between two typing notices to the same channel. Short of the
/// ten seconds an indicator lasts, so it never lapses mid-sentence.
const TYPING_EVERY: std::time::Duration = std::time::Duration::from_secs(8);
/// Whether a typing notice to this channel is due, noting it if so.
fn typing_due(channel_id: &str) -> bool {
static LAST: std::sync::Mutex<Option<HashMap<String, std::time::Instant>>> = std::sync::Mutex::new(None);
let mut last = LAST.lock().unwrap();
let last = last.get_or_insert_with(HashMap::new);
let now = std::time::Instant::now();
if last.get(channel_id).is_some_and(|at| now.duration_since(*at) < TYPING_EVERY) {
return false;
}
last.retain(|_, at| now.duration_since(*at) < TYPING_EVERY);
last.insert(channel_id.to_string(), now);
true
}
/// Tells Discord this conversation has been read up to its newest message.
///
/// Unread was purely local before this, so reading a channel here did not
@@ -823,7 +870,16 @@ pub async fn ack_read(state: &AppState, buffer_id: &str, token: &str) -> Result<
.runtime
.get_discord_channel(buffer_id)
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
let Some(msg_id) = state.store.newest_msg_id(buffer_id)? else { return Ok(()) };
let Some((msg_id, own)) = state.store.newest_msg(buffer_id)? else { return Ok(()) };
// Nothing to tell Discord about a conversation whose last word is the account's own: sending a message reads
// everything before it, on every device, and Discord's own clients do not ack what they sent.
if own {
return Ok(());
}
// Reading a channel again with nothing new in it says nothing new.
if !ack_due(&channel_id, &msg_id) {
return Ok(());
}
let resp = send_write(
http_client_for(token)
.post(format!("{API_BASE}/channels/{channel_id}/messages/{msg_id}/ack"))
@@ -831,8 +887,10 @@ pub async fn ack_read(state: &AppState, buffer_id: &str, token: &str) -> Result<
.json(&json!({ "token": serde_json::Value::Null }))
)
.await
.inspect_err(|_| forget_ack(&channel_id))
.context("acking Discord read state")?;
if !resp.status().is_success() {
forget_ack(&channel_id);
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("{}", refusal_text(status, &text));
@@ -840,6 +898,44 @@ pub async fn ack_read(state: &AppState, buffer_id: &str, token: &str) -> Result<
Ok(())
}
/// The newest message each channel was last acked up to, this run.
static ACKED: std::sync::Mutex<Option<HashMap<String, String>>> = std::sync::Mutex::new(None);
/// Whether acking `msg_id` in this channel would say anything new, noting it
/// if so.
fn ack_due(channel_id: &str, msg_id: &str) -> bool {
let mut acked = ACKED.lock().unwrap();
let acked = acked.get_or_insert_with(HashMap::new);
if acked.get(channel_id).is_some_and(|id| id == msg_id) {
return false;
}
acked.insert(channel_id.to_string(), msg_id.to_string());
true
}
/// Another client of the account read this far: no need to say it again from here.
pub(super) fn acked_elsewhere(channel_id: &str, msg_id: &str) {
let mut acked = ACKED.lock().unwrap();
// Never backwards: an ack from a device that is behind does not make this one's later ack redundant.
let newer = match acked.get_or_insert_with(HashMap::new).get(channel_id) {
Some(known) => match (known.parse::<u64>(), msg_id.parse::<u64>()) {
(Ok(k), Ok(m)) => m > k,
_ => true,
},
None => true,
};
if newer {
acked.get_or_insert_with(HashMap::new).insert(channel_id.to_string(), msg_id.to_string());
}
}
/// A refused ack must be tried again, not remembered as done.
fn forget_ack(channel_id: &str) {
if let Some(acked) = ACKED.lock().unwrap().as_mut() {
acked.remove(channel_id);
}
}
pub async fn delete_message(state: &AppState, buffer_id: &str, token: &str, msg_id: &str) -> Result<()> {
let channel_id = state
.runtime
@@ -949,3 +1045,19 @@ mod refusal_tests {
assert_eq!(describe_seconds(45), "45 seconds");
}
}
#[cfg(test)]
mod ack_tests {
use super::*;
#[test]
fn an_ack_made_elsewhere_is_not_made_again_and_never_moves_backwards() {
// Own ids for this test: the table is shared by the process.
let channel = "ack-test-channel";
acked_elsewhere(channel, "200");
assert!(!ack_due(channel, "200"), "read this far on another device");
assert!(ack_due(channel, "300"), "something newer is still to be told");
acked_elsewhere(channel, "250");
assert!(!ack_due(channel, "300"), "an older ack from elsewhere does not undo what was told");
}
}
+695
View File
@@ -0,0 +1,695 @@
//! What this client will say on Discord, and when.
//!
//! Discord judges an account by what it sends, and it judges the first thing said somewhere hardest. Accounts that
//! start talking in a server, or to a person, from a client that is not Discord's own have been flagged for spam - and
//! logged out of everywhere - on the strength of that one message, however ordinary it was; the same account's later
//! messages, in places it has already spoken, are left alone. So this client does not say the first word:
//!
//! - **A server** where the account has never spoken since it joined is locked, every channel in it. Whether it has
//! spoken is asked of what is stored here and then of Discord's own search (one request a server, the answer kept),
//! and the lock lifts as soon as there is a message of the account's there - including one sent from Discord's own
//! app while this one is running.
//! - **A conversation** with no messages in it at all is locked the same way, and lifts when one arrives.
//!
//! The lock is the daemon's, not the window's: a send to a locked place is refused here, whoever asks. The window shows
//! the reason where the message box would be, through the conversation's `read_only`.
//!
//! The rest is how what is sent goes out: a `nonce` on every message and an explicit `allowed_mentions`, as Discord's
//! clients send; a file checked against what the account may upload before any of it leaves; and a pause between a
//! message and a file, since a file sent straight after a message is what has been flagged too.
use super::*;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// Shown where the box would be in a server the account has not spoken in.
pub const FIRST_WORD: &str =
"Send your first message in this server from Discord's own app or website. Discord flags accounts that start talking in a server from another client.";
/// Shown while that is being found out.
pub const CHECKING: &str = "Checking whether you have spoken in this server before…";
/// Shown in a conversation with no messages in it.
pub const EMPTY_DM: &str =
"Start this conversation from Discord's own app or website. Discord flags accounts that send the first message to somebody from another client.";
/// Why a place is closed to this client, and nothing else.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lock {
None,
Checking,
FirstWord,
EmptyDm,
}
impl Lock {
pub fn reason(self) -> Option<&'static str> {
match self {
Lock::None => None,
Lock::Checking => Some(CHECKING),
Lock::FirstWord => Some(FIRST_WORD),
Lock::EmptyDm => Some(EMPTY_DM),
}
}
}
/// What is known, per process. Plain data and plain methods so it can be tested without a connection.
#[derive(Default)]
pub struct Guard {
/// Account -> the servers it is known to have spoken in.
spoken: HashMap<String, HashSet<String>>,
/// Servers being looked into now.
checking: HashSet<(String, String)>,
/// Servers already looked into this run, whatever came of it: a server is not searched again every time it is read.
checked: HashSet<(String, String)>,
/// When the account joined each server (Unix seconds), where Discord said.
joined: HashMap<(String, String), i64>,
/// Each server's boost tier, which raises what may be uploaded there.
tiers: HashMap<(String, String), u8>,
/// Why a conversation is read-only for a reason that is not ours (no permission, Discord's own notices).
permission: HashMap<String, Option<&'static str>>,
/// Conversations that have no messages in them.
empty_dms: HashSet<String>,
/// When this account last sent a message, and whether it carried a file.
last_sent: HashMap<String, (Instant, bool)>,
/// The messages this client has sent, by id. They are the account's own, but they are not what the lock asks for:
/// it wants a first word from Discord's own client.
sent: HashSet<String>,
sent_order: std::collections::VecDeque<String>,
}
#[derive(Serialize, Deserialize, Default)]
struct Saved {
accounts: HashMap<String, Vec<String>>,
/// Ids of messages this client sent, oldest first.
#[serde(default)]
sent: Vec<String>,
}
/// How many sent messages are remembered: enough to outlast the rest of the history that could be mistaken for a
/// first word, and no more.
const REMEMBER_SENT: usize = 5000;
impl Guard {
pub fn is_spoken(&self, account: &str, guild: &str) -> bool {
self.spoken.get(account).is_some_and(|g| g.contains(guild))
}
/// What closes a place: `guild` is None for a conversation.
pub fn lock_of(&self, account: &str, guild: Option<&str>, buffer: &str) -> Lock {
match guild {
Some(guild) if self.is_spoken(account, guild) => Lock::None,
Some(guild) if self.checking.contains(&(account.to_string(), guild.to_string())) => Lock::Checking,
Some(_) => Lock::FirstWord,
None if self.empty_dms.contains(buffer) => Lock::EmptyDm,
None => Lock::None,
}
}
/// The reason a conversation is read-only: a permission the account lacks first, since that is the truer thing to
/// say, then the lock.
pub fn effective(&self, account: &str, guild: Option<&str>, buffer: &str) -> Option<&'static str> {
self.permission.get(buffer).copied().flatten().or_else(|| self.lock_of(account, guild, buffer).reason())
}
}
impl Guard {
pub fn note_sent(&mut self, id: &str) -> bool {
if !self.sent.insert(id.to_string()) {
return false;
}
self.sent_order.push_back(id.to_string());
while self.sent_order.len() > REMEMBER_SENT {
if let Some(old) = self.sent_order.pop_front() {
self.sent.remove(&old);
}
}
true
}
/// Whether any of these message ids is one this client did not send.
pub fn any_not_sent_here<'a>(&self, ids: impl IntoIterator<Item = &'a str>) -> bool {
ids.into_iter().any(|id| !self.sent.contains(id))
}
}
/// A message this client has just sent, from what Discord answered: remembered as sent here.
pub(super) async fn note_sent(resp: reqwest::Response) {
if let Ok(body) = resp.json::<Value>().await {
if let Some(id) = body["id"].as_str() {
with(|g| {
if g.note_sent(id) {
save(g);
}
});
}
}
}
/// The most characters a message may hold: 4,000 with Nitro, 2,000 without.
pub fn message_limit(premium: u8) -> usize {
if premium == 2 {
4000
} else {
2000
}
}
/// Refuses text Discord would refuse, or nothing at all, before it is sent. A request that is bound to be turned down
/// is one more request from an account that is being watched, and says nothing a sentence here does not say better.
pub fn check_text(content: &str, premium: u8, may_be_empty: bool) -> Result<()> {
let limit = message_limit(premium);
let length = content.chars().count();
if length > limit {
bail!("That message is {length} characters long and Discord takes {limit}{}", if premium == 2 { "" } else { " (4,000 with Nitro)" });
}
if !may_be_empty && content.trim().is_empty() {
bail!("There is nothing to send");
}
Ok(())
}
/// The most a file may be where it is sent: what the account's Nitro allows, or what the server's boosts do, whichever
/// is more.
pub fn upload_limit(premium: u8, guild_tier: u8) -> u64 {
const MIB: u64 = 1024 * 1024;
let account = match premium {
2 => 500 * MIB,
1 | 3 => 50 * MIB,
_ => 10 * MIB,
};
let guild = match guild_tier {
3 => 100 * MIB,
2 => 50 * MIB,
_ => 10 * MIB,
};
account.max(guild)
}
fn file() -> std::path::PathBuf {
crate::default_data_dir().join("discord-spoken.json")
}
static GUARD: Mutex<Option<Guard>> = Mutex::new(None);
/// Runs `f` on the process's state, loading what was kept the first time.
fn with<R>(f: impl FnOnce(&mut Guard) -> R) -> R {
let mut slot = GUARD.lock().unwrap_or_else(|e| e.into_inner());
let guard = slot.get_or_insert_with(|| {
let saved: Saved = std::fs::read_to_string(file()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
Guard {
spoken: saved.accounts.into_iter().map(|(a, g)| (a, g.into_iter().collect())).collect(),
sent: saved.sent.iter().cloned().collect(),
sent_order: saved.sent.into_iter().collect(),
..Default::default()
}
});
f(guard)
}
fn save(guard: &Guard) {
let saved = Saved {
accounts: guard.spoken.iter().map(|(a, g)| (a.clone(), g.iter().cloned().collect())).collect(),
sent: guard.sent_order.iter().cloned().collect(),
};
if let Ok(text) = serde_json::to_string(&saved) {
if let Some(dir) = file().parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(file(), text);
}
}
/// Says why a conversation is read-only for a reason of its own (or that it is not), and applies what the lock adds.
pub(super) fn set_permission(state: &AppState, account: &str, buffer: &str, reason: Option<&'static str>) {
let guild = state.runtime.get_discord_guild(buffer);
let effective = with(|g| {
g.permission.insert(buffer.to_string(), reason);
g.effective(account, guild.as_deref(), buffer)
});
state.runtime.set_buffer_read_only(state, buffer, effective);
}
/// Puts every conversation in a server back to what it should say now.
fn reapply_guild(state: &AppState, account: &str, guild: &str) {
for buffer in state.runtime.discord_buffers_in_guild(account, guild) {
let effective = with(|g| g.effective(account, Some(guild), &buffer));
state.runtime.set_buffer_read_only(state, &buffer, effective);
}
}
/// A server is being registered: note what is wanted of it, and start finding out whether the account has spoken in
/// it. Called before its channels are registered, so they begin locked rather than open.
pub(super) fn open_guild(state: &AppState, config: &DiscordAccountConfig, guild: &Value) {
let Some(guild_id) = guild["id"].as_str() else { return };
let account = config.account_id();
let key = (account.clone(), guild_id.to_string());
let joined = guild["joined_at"].as_str().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| t.timestamp());
let start = with(|g| {
if let Some(at) = joined {
g.joined.insert(key.clone(), at);
}
g.tiers.insert(key.clone(), guild["premium_tier"].as_u64().unwrap_or(0).min(3) as u8);
if g.is_spoken(&account, guild_id) || g.checking.contains(&key) || g.checked.contains(&key) {
return false;
}
g.checking.insert(key.clone());
true
});
if start {
spawn_check(state.clone(), config.clone(), guild_id.to_string());
}
}
/// An account has said something in a server: it has spoken there.
pub(super) fn spoke_in_guild(state: &AppState, account: &str, guild: &str) {
let changed = with(|g| {
let key = (account.to_string(), guild.to_string());
let was_checking = g.checking.remove(&key);
let new = g.spoken.entry(account.to_string()).or_default().insert(guild.to_string());
if new {
save(g);
}
new || was_checking
});
if changed {
reapply_guild(state, account, guild);
}
}
/// A server is gone (left, or removed from): speaking there again, if it is joined again, is a first word again.
pub(super) fn forget_guild(account: &str, guild: &str) {
with(|g| {
if let Some(set) = g.spoken.get_mut(account) {
if set.remove(guild) {
save(g);
}
}
let key = (account.to_string(), guild.to_string());
g.checking.remove(&key);
g.checked.remove(&key);
g.joined.remove(&key);
g.tiers.remove(&key);
});
}
/// An account is removed: nothing of it is kept.
pub fn forget_account(account: &str) {
with(|g| {
if g.spoken.remove(account).is_some() {
save(g);
}
g.checking.retain(|(a, _)| a != account);
g.checked.retain(|(a, _)| a != account);
g.joined.retain(|(a, _), _| a != account);
g.tiers.retain(|(a, _), _| a != account);
g.last_sent.remove(account);
});
}
/// A conversation as it was registered: empty ones are locked.
pub(super) fn note_dm(state: &AppState, account: &str, buffer: &str, channel: &Value) {
// Only a channel that says it has no messages: one that does not say is not known to be empty.
let empty = channel.get("last_message_id").is_some_and(Value::is_null) && state.store.newest_msg_id(buffer).ok().flatten().is_none();
with(|g| {
if empty {
g.empty_dms.insert(buffer.to_string());
} else {
g.empty_dms.remove(buffer);
}
});
let effective = with(|g| g.effective(account, None, buffer));
state.runtime.set_buffer_read_only(state, buffer, effective);
}
/// A message has arrived in a conversation, so it is not empty any more.
pub(super) fn dm_has_messages(state: &AppState, account: &str, buffer: &str) {
let was_empty = with(|g| g.empty_dms.remove(buffer));
if was_empty {
let effective = with(|g| g.effective(account, None, buffer));
state.runtime.set_buffer_read_only(state, buffer, effective);
}
}
/// A dispatch for a message was received in a place this connection knows: whatever it means for the locks.
pub(super) fn heard(state: &AppState, config: &DiscordAccountConfig, d: &Value, buffer: &str, is_dm: bool) {
let account = config.account_id();
if is_dm {
dm_has_messages(state, &account, buffer);
} else if d["author"]["id"].as_str() == Some(config.user_id.as_str()) {
if let Some(guild) = d["guild_id"].as_str() {
spoke_in_guild(state, &account, guild);
}
}
}
/// Refuses a send to a place the account has not spoken in.
pub(super) fn ensure_may_speak(state: &AppState, buffer: &str) -> Result<()> {
let account = state.runtime.get_buffer(buffer).map(|b| b.account_id).unwrap_or_default();
let guild = state.runtime.get_discord_guild(buffer);
match with(|g| g.lock_of(&account, guild.as_deref(), buffer)).reason() {
Some(reason) => bail!("{reason}"),
None => Ok(()),
}
}
/// Refuses a file Discord would refuse, before any of it is sent. What Discord does with one that is too big is cut
/// the upload off, which looks like nothing a client does; saying so first is the better answer.
pub(super) fn ensure_fits(state: &AppState, buffer: &str, files: &[(String, String, u64)]) -> Result<()> {
let account = state.runtime.get_buffer(buffer).map(|b| b.account_id).unwrap_or_default();
let guild = state.runtime.get_discord_guild(buffer);
let tier = guild.as_deref().and_then(|g| with(|s| s.tiers.get(&(account.clone(), g.to_string())).copied())).unwrap_or(0);
let limit = upload_limit(state.runtime.discord_premium(&account), tier);
for (_, name, len) in files {
if *len > limit {
bail!("{name} is {} and the most you can send here is {}", human(*len), human(limit));
}
}
Ok(())
}
fn human(bytes: u64) -> String {
const MIB: f64 = 1024.0 * 1024.0;
format!("{:.1} MB", bytes as f64 / MIB)
}
/// How long to wait before this account sends another message, given what it sent last: a file straight after a
/// message, or a message straight after a file, is closer together than anybody working a client sends them.
pub fn gap_needed(last: Option<(Duration, bool)>, attachment: bool) -> Duration {
const FILE_AFTER_MESSAGE: Duration = Duration::from_secs(5);
const MESSAGE_AFTER_FILE: Duration = Duration::from_secs(3);
let Some((since, last_was_file)) = last else { return Duration::ZERO };
let needed = match (attachment, last_was_file) {
(true, _) => FILE_AFTER_MESSAGE,
(false, true) => MESSAGE_AFTER_FILE,
(false, false) => Duration::ZERO,
};
needed.saturating_sub(since)
}
/// Waits out the pause, and takes this send's turn.
pub(super) async fn take_turn(account: &str, attachment: bool) {
let wait = with(|g| {
let last = g.last_sent.get(account).map(|(at, file)| (at.elapsed(), *file));
let wait = gap_needed(last, attachment);
g.last_sent.insert(account.to_string(), (Instant::now() + wait, attachment));
wait
});
if !wait.is_zero() {
tokio::time::sleep(wait).await;
}
}
/// A fresh message id, as Discord's clients make one: a snowflake from now, so it sorts with the messages around it
/// and cannot repeat.
pub fn nonce() -> String {
const EPOCH_MS: i64 = 1_420_070_400_000;
let now = chrono::Utc::now().timestamp_millis();
let low: u64 = rand::random::<u64>() & ((1 << 22) - 1);
((((now - EPOCH_MS).max(0) as u64) << 22) | low).to_string()
}
/// What every message carries beyond its words: the nonce, and who may be mentioned, said outright.
pub(super) fn stamp(payload: &mut Value) {
if payload.get("nonce").is_none() {
payload["nonce"] = Value::from(nonce());
}
if payload.get("allowed_mentions").is_none() {
let mut allowed = json!({ "parse": ["users", "roles", "everyone"] });
if payload.get("message_reference").is_some() {
allowed["replied_user"] = Value::Bool(true);
}
payload["allowed_mentions"] = allowed;
}
}
// --- finding out whether the account has spoken in a server ---------------------------------------------------------
/// One server at a time, a little apart: a search is the heaviest thing this asks of Discord.
static TURN: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
const SPACING: Duration = Duration::from_millis(2500);
const INDEXING_TRIES: usize = 6;
/// What Discord's search said of the account's messages in a server.
#[derive(Debug, PartialEq, Eq)]
pub enum Searched {
Spoke,
Never,
/// Still indexing, or asked to slow down: try again after this long.
Wait(Duration),
}
/// Reads a search answer. The count is Discord's own; a 202 or a 429 is not an answer. Messages that this client sent
/// are the account's but are not the first word the lock waits for: it is `Spoke` only if one of the hits was sent
/// from somewhere else, or if there are more results than the hits shown (which cannot all be ours).
pub fn read_search(status: u16, retry_after: Option<f64>, body: &Value, sent_here: &HashSet<String>) -> Result<Searched> {
match status {
200 => {
let total = body["total_results"].as_u64().unwrap_or(0);
let hits: Vec<&str> = body["messages"]
.as_array()
.into_iter()
.flatten()
.filter_map(|group| group.as_array()?.iter().find(|m| m["hit"].as_bool() == Some(true)).or_else(|| group.as_array()?.first()))
.filter_map(|m| m["id"].as_str())
.collect();
let elsewhere = hits.iter().any(|id| !sent_here.contains(*id));
Ok(if total > 0 && (elsewhere || total as usize > hits.len()) { Searched::Spoke } else { Searched::Never })
}
202 | 429 => {
let seconds = retry_after.or_else(|| body["retry_after"].as_f64()).unwrap_or(5.0).clamp(1.0, 60.0);
Ok(Searched::Wait(Duration::from_secs_f64(seconds)))
}
other => bail!("Discord answered {other} to the search"),
}
}
fn spawn_check(state: AppState, config: DiscordAccountConfig, guild_id: String) {
tokio::spawn(async move {
// The server's channels are registered a moment after this is started.
tokio::time::sleep(Duration::from_secs(2)).await;
let account = config.account_id();
let key = (account.clone(), guild_id.clone());
let since = with(|g| g.joined.get(&key).copied());
// What is kept here first: no request for what is already known, and no waiting behind the searches.
let buffers = state.runtime.discord_buffers_in_guild(&account, &guild_id);
let kept = state.store.own_message_ids_since(&buffers, since.unwrap_or(0), 200).unwrap_or_default();
if with(|g| g.any_not_sent_here(kept.iter().map(String::as_str))) {
spoke_in_guild(&state, &account, &guild_id);
return;
}
// Then Discord's own search, one server at a time.
let _turn = TURN.lock().await;
let mut spoke = false;
for attempt in 0..INDEXING_TRIES {
match search_own(&config, &guild_id, since).await {
Ok(Searched::Spoke) => {
spoke = true;
break;
}
Ok(Searched::Never) => break,
Ok(Searched::Wait(wait)) => {
tracing::debug!("discord[{account}]: searching {guild_id}, try {attempt}: waiting {}s", wait.as_secs());
tokio::time::sleep(wait).await;
}
Err(e) => {
// Not known either way. Left locked - which is the cautious answer - and tried again at the next start.
tracing::warn!("discord[{account}]: could not look for your messages in {guild_id}: {e:#}");
break;
}
}
}
if spoke {
spoke_in_guild(&state, &account, &guild_id);
} else {
with(|g| {
g.checking.remove(&key);
g.checked.insert(key.clone());
});
reapply_guild(&state, &account, &guild_id);
tracing::info!("discord[{account}]: no message of yours in {guild_id} since joining: locked until you send one from Discord's own client");
}
tokio::time::sleep(SPACING).await;
});
}
/// Asks Discord whether the account has any message in a server since it joined.
async fn search_own(config: &DiscordAccountConfig, guild_id: &str, since: Option<i64>) -> Result<Searched> {
let mut query = vec![("author_id".to_string(), config.user_id.clone()), ("include_nsfw".to_string(), "true".to_string())];
if let Some(at) = since {
query.push(("min_id".to_string(), super::search::snowflake_at(at)));
}
let resp = http_client_for(&config.token)
.get(format!("{API_BASE}/guilds/{guild_id}/messages/search"))
.query(&query)
.header("Authorization", &config.token)
.send()
.await
.context("searching for your messages")?;
let status = resp.status().as_u16();
let retry = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<f64>().ok());
let body: Value = resp.json().await.unwrap_or(Value::Null);
let sent_here = with(|g| g.sent.clone());
read_search(status, retry, &body, &sent_here)
}
#[cfg(test)]
mod tests {
use super::*;
fn guard() -> Guard {
Guard::default()
}
#[test]
fn a_server_never_spoken_in_is_locked_and_one_spoken_in_is_not() {
let mut g = guard();
assert_eq!(g.lock_of("a", Some("g"), "b"), Lock::FirstWord);
g.checking.insert(("a".into(), "g".into()));
assert_eq!(g.lock_of("a", Some("g"), "b"), Lock::Checking);
g.spoken.entry("a".into()).or_default().insert("g".into());
assert_eq!(g.lock_of("a", Some("g"), "b"), Lock::None);
}
#[test]
fn speaking_in_one_account_does_not_open_another() {
let mut g = guard();
g.spoken.entry("a".into()).or_default().insert("g".into());
assert_eq!(g.lock_of("other", Some("g"), "b"), Lock::FirstWord);
assert_eq!(g.lock_of("a", Some("elsewhere"), "b"), Lock::FirstWord);
}
#[test]
fn an_empty_conversation_is_locked_and_one_with_messages_is_not() {
let mut g = guard();
assert_eq!(g.lock_of("a", None, "dm"), Lock::None);
g.empty_dms.insert("dm".into());
assert_eq!(g.lock_of("a", None, "dm"), Lock::EmptyDm);
}
#[test]
fn a_missing_permission_is_said_before_the_lock() {
let mut g = guard();
g.permission.insert("b".into(), Some("You may not send here."));
assert_eq!(g.effective("a", Some("g"), "b"), Some("You may not send here."));
g.permission.insert("b".into(), None);
assert_eq!(g.effective("a", Some("g"), "b"), Some(FIRST_WORD));
g.spoken.entry("a".into()).or_default().insert("g".into());
assert_eq!(g.effective("a", Some("g"), "b"), None);
}
#[test]
fn what_may_be_uploaded_is_the_more_of_the_account_and_the_server() {
const MIB: u64 = 1024 * 1024;
assert_eq!(upload_limit(0, 0), 10 * MIB);
assert_eq!(upload_limit(2, 0), 500 * MIB);
assert_eq!(upload_limit(3, 0), 50 * MIB);
assert_eq!(upload_limit(0, 2), 50 * MIB);
assert_eq!(upload_limit(0, 3), 100 * MIB);
assert_eq!(upload_limit(3, 3), 100 * MIB);
assert_eq!(upload_limit(2, 3), 500 * MIB);
}
#[test]
fn a_file_waits_after_a_message_and_a_message_after_a_file() {
let s = Duration::from_secs;
assert_eq!(gap_needed(None, true), Duration::ZERO);
assert_eq!(gap_needed(Some((s(1), false)), true), s(4));
assert_eq!(gap_needed(Some((s(9), false)), true), Duration::ZERO);
assert_eq!(gap_needed(Some((s(1), true)), false), s(2));
assert_eq!(gap_needed(Some((s(1), true)), true), s(4));
// Messages one after the other are talking, and are not slowed here.
assert_eq!(gap_needed(Some((Duration::ZERO, false)), false), Duration::ZERO);
}
#[test]
fn text_discord_would_refuse_is_refused_here() {
assert!(check_text("hello", 0, false).is_ok());
assert!(check_text(&"a".repeat(2000), 0, false).is_ok());
let long = check_text(&"a".repeat(2001), 0, false).unwrap_err().to_string();
assert!(long.contains("2001") && long.contains("2000") && long.contains("Nitro"), "{long}");
assert!(check_text(&"a".repeat(4000), 2, false).is_ok(), "Nitro takes more");
assert!(check_text(&"a".repeat(4001), 2, false).is_err());
// Counted in characters, not bytes.
assert!(check_text(&"é".repeat(2000), 0, false).is_ok());
assert!(check_text(" \n ", 0, false).is_err(), "nothing is nothing");
assert!(check_text("", 0, true).is_ok(), "a caption may be empty");
}
#[test]
fn a_nonce_is_a_number_that_does_not_repeat() {
let a = nonce();
let b = nonce();
assert_ne!(a, b);
assert!(a.parse::<u64>().is_ok());
assert!(a.len() >= 18, "{a}");
}
#[test]
fn every_message_is_stamped_and_what_is_already_said_is_kept() {
let mut plain = json!({ "content": "hi" });
stamp(&mut plain);
assert!(plain["nonce"].as_str().is_some());
assert_eq!(plain["allowed_mentions"]["parse"], json!(["users", "roles", "everyone"]));
assert!(plain["allowed_mentions"].get("replied_user").is_none());
let mut reply = json!({ "content": "hi", "message_reference": { "message_id": "1" } });
stamp(&mut reply);
assert_eq!(reply["allowed_mentions"]["replied_user"], json!(true));
let mut quiet = json!({ "content": "hi", "message_reference": { "message_id": "1" }, "allowed_mentions": { "parse": [], "replied_user": false }, "nonce": "7" });
stamp(&mut quiet);
assert_eq!(quiet["nonce"], json!("7"));
assert_eq!(quiet["allowed_mentions"]["replied_user"], json!(false));
}
fn hit(id: &str) -> Value {
json!([{ "id": id, "hit": true }])
}
#[test]
fn a_search_answer_is_read_as_spoke_never_or_wait() {
let none = HashSet::new();
let one = json!({ "total_results": 1, "messages": [hit("9")] });
assert_eq!(read_search(200, None, &one, &none).unwrap(), Searched::Spoke);
assert_eq!(read_search(200, None, &json!({ "total_results": 0, "messages": [] }), &none).unwrap(), Searched::Never);
assert_eq!(read_search(202, None, &json!({ "retry_after": 7.0 }), &none).unwrap(), Searched::Wait(Duration::from_secs(7)));
assert_eq!(read_search(429, Some(2.0), &Value::Null, &none).unwrap(), Searched::Wait(Duration::from_secs(2)));
// Not an answer: neither "never" nor "spoke".
assert!(read_search(403, None, &Value::Null, &none).is_err());
assert!(read_search(500, None, &Value::Null, &none).is_err());
// A wait is never an hour.
assert_eq!(read_search(202, Some(9999.0), &Value::Null, &none).unwrap(), Searched::Wait(Duration::from_secs(60)));
}
#[test]
fn what_this_client_sent_is_not_the_first_word_the_lock_waits_for() {
let sent: HashSet<String> = ["9".to_string()].into();
let mine = json!({ "total_results": 1, "messages": [hit("9")] });
assert_eq!(read_search(200, None, &mine, &sent).unwrap(), Searched::Never);
let other = json!({ "total_results": 2, "messages": [hit("9"), hit("10")] });
assert_eq!(read_search(200, None, &other, &sent).unwrap(), Searched::Spoke);
// More results than hits shown: they cannot all be the one this client sent.
let many = json!({ "total_results": 40, "messages": [hit("9")] });
assert_eq!(read_search(200, None, &many, &sent).unwrap(), Searched::Spoke);
}
#[test]
fn sent_messages_are_remembered_a_bounded_number() {
let mut g = guard();
assert!(g.note_sent("1"));
assert!(!g.note_sent("1"), "twice is once");
assert!(g.any_not_sent_here(["1", "2"]));
assert!(!g.any_not_sent_here(["1"]));
for i in 0..(REMEMBER_SENT + 10) {
g.note_sent(&format!("m{i}"));
}
assert_eq!(g.sent.len(), REMEMBER_SENT);
assert!(!g.sent.contains("1") && !g.sent.contains("m0") && g.sent.contains(&format!("m{}", REMEMBER_SENT + 9)));
}
}
+304
View File
@@ -0,0 +1,304 @@
//! A gateway session kept on disk, so a restart can pick it up instead of logging in afresh.
//!
//! Discord keeps a dropped session for a few minutes and replays what it missed to a client that resumes it; its own
//! client resumes only when the last heartbeat was acknowledged under three minutes ago, and logs in afresh otherwise.
//! A restart of moho is a dropped connection like any other, so it does the same - for which the session has to
//! survive the process: what to resume (`Position`), and what a resume does *not* send again, because Discord sends
//! READY and GUILD_CREATE only to a fresh login (`State`). The state is replayed through the very code that handles
//! them live, so a resumed start ends up with the same friends, servers, mutes and plan as a fresh one.
//!
//! Two files per account, because the position changes every heartbeat and the state hardly ever.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::PathBuf;
/// How old the last acknowledged heartbeat may be for a session to be worth resuming. Discord's client uses three
/// minutes; a little under, since connecting takes time and a refused resume costs a fresh login.
pub const FRESH_FOR: i64 = 150;
/// Past this the state is not kept (and so nothing is resumed): a few huge servers are not worth a file that size
/// rewritten on every change.
const MAX_STATE_BYTES: usize = 24 * 1024 * 1024;
#[derive(Serialize, Deserialize, Clone)]
pub struct Position {
pub user_id: String,
/// Which token this session belongs to, as a hash - the token itself is not written here.
pub token: String,
pub session_id: String,
pub url: String,
pub seq: u64,
/// When a heartbeat was last acknowledged, Unix seconds.
pub acked_at: i64,
}
#[derive(Serialize, Deserialize, Default)]
pub struct State {
pub session_id: String,
/// READY's payload, with the direct messages found over REST added as `moho_dms`.
pub ready: Value,
/// Each server's GUILD_CREATE payload, by id.
pub guilds: HashMap<String, Value>,
}
/// One account's session files, and what is known of them in memory.
#[derive(Default)]
pub struct SessionFile {
/// Where the files go; the daemon's data directory unless a test says otherwise.
root: Option<PathBuf>,
account: String,
user_id: String,
token: String,
position: Option<Position>,
state: Option<State>,
state_dirty: bool,
}
pub fn token_hash(token: &str) -> String {
Sha256::digest(token.as_bytes()).iter().take(12).map(|b| format!("{b:02x}")).collect()
}
fn default_dir() -> PathBuf {
crate::default_data_dir().join("discord-sessions")
}
/// Whether a saved position can still be resumed at `now`, by this token.
pub fn resumable(position: &Position, user_id: &str, token: &str, now: i64) -> bool {
position.user_id == user_id && position.token == token_hash(token) && now - position.acked_at < FRESH_FOR && position.acked_at <= now + 5
}
impl SessionFile {
fn dir(&self) -> PathBuf {
self.root.clone().unwrap_or_else(default_dir)
}
fn path(&self, what: &str) -> PathBuf {
let safe: String = self.account.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect();
self.dir().join(format!("{safe}.{what}.json"))
}
pub fn new(account: &str, user_id: &str, token: &str) -> Self {
SessionFile { account: account.to_string(), user_id: user_id.to_string(), token: token_hash(token), ..Default::default() }
}
/// The saved session as READY and GUILD_CREATE dispatches in the order they were first received, with the
/// position to resume from; `None` when there is nothing fresh enough to resume.
pub fn load(&mut self, token: &str, now: i64) -> Option<(Position, Vec<Value>)> {
let position: Position = serde_json::from_str(&std::fs::read_to_string(self.path("pos")).ok()?).ok()?;
if !resumable(&position, &self.user_id, token, now) {
return None;
}
let state: State = serde_json::from_str(&std::fs::read_to_string(self.path("state")).ok()?).ok()?;
if state.session_id != position.session_id || state.ready.is_null() {
return None;
}
let mut replay = vec![dispatch("READY", position.seq, state.ready.clone())];
replay.extend(state.guilds.values().map(|g| dispatch("GUILD_CREATE", position.seq, g.clone())));
self.position = Some(position.clone());
self.state = Some(state);
Some((position, replay))
}
/// A fresh READY: a new session, with nothing of the last one worth keeping.
pub fn ready(&mut self, session_id: &str, url: &str, seq: u64, ready: Value) {
self.position = Some(Position {
user_id: self.user_id.clone(),
token: self.token.clone(),
session_id: session_id.to_string(),
url: url.to_string(),
seq,
acked_at: chrono::Utc::now().timestamp(),
});
self.state = Some(State { session_id: session_id.to_string(), ready, guilds: HashMap::new() });
self.state_dirty = true;
}
/// The direct messages listed over REST at READY, kept with it: a resume does not list them again.
pub fn dms(&mut self, list: Vec<Value>) {
if let Some(state) = self.state.as_mut() {
state.ready["moho_dms"] = Value::Array(list);
self.state_dirty = true;
}
}
pub fn guild(&mut self, id: &str, payload: &Value) {
if let Some(state) = self.state.as_mut() {
state.guilds.insert(id.to_string(), payload.clone());
self.state_dirty = true;
}
}
pub fn guild_gone(&mut self, id: &str) {
if let Some(state) = self.state.as_mut() {
if state.guilds.remove(id).is_some() {
self.state_dirty = true;
}
}
}
/// A heartbeat was acknowledged: the session is alive as of now, and has seen events up to `seq`.
pub fn acked(&mut self, seq: u64) {
let Some(position) = self.position.as_mut() else { return };
position.seq = seq;
position.acked_at = chrono::Utc::now().timestamp();
self.write();
}
/// The session is gone (refused, or ended by Discord): nothing is left to resume.
pub fn clear(&mut self) {
self.position = None;
self.state = None;
self.state_dirty = false;
let _ = std::fs::remove_file(self.path("pos"));
let _ = std::fs::remove_file(self.path("state"));
}
fn write(&mut self) {
let (Some(position), Some(state)) = (self.position.as_ref(), self.state.as_ref()) else { return };
let _ = std::fs::create_dir_all(self.dir());
if self.state_dirty {
match serde_json::to_string(state) {
Ok(text) if text.len() <= MAX_STATE_BYTES => {
if write_private(&self.path("state"), &text) {
self.state_dirty = false;
}
}
_ => {
// Too big to keep: this session cannot be resumed after a restart, which is the cost of that
// many servers.
let _ = std::fs::remove_file(self.path("pos"));
self.position = None;
self.state = None;
return;
}
}
}
if let Ok(text) = serde_json::to_string(position) {
write_private(&self.path("pos"), &text);
}
}
}
/// Removes whatever is kept for an account that has been taken away.
pub fn forget(account: &str) {
let mut file = SessionFile { account: account.to_string(), ..Default::default() };
file.clear();
}
fn dispatch(kind: &str, seq: u64, d: Value) -> Value {
serde_json::json!({ "op": 0, "t": kind, "s": seq, "d": d, "moho_replay": true })
}
/// Written to a temporary file and renamed, so a restart in the middle of it never reads half a file; readable by
/// this user alone, as the session is as good as a token for as long as it lives.
fn write_private(path: &std::path::Path, text: &str) -> bool {
let tmp = path.with_extension("tmp");
let ok = (|| -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
f.write_all(text.as_bytes())?;
}
#[cfg(not(unix))]
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)
})();
ok.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A directory of its own, gone when the test is.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("nobilis-session-store-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Scratch(dir)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn in_dir(dir: &std::path::Path) -> SessionFile {
SessionFile { root: Some(dir.to_path_buf()), ..SessionFile::new("discord:7", "7", "tok") }
}
#[test]
fn a_saved_session_comes_back_as_the_dispatches_it_was_made_of() {
let dir = Scratch::new("comes_back");
let mut first = in_dir(dir.path());
first.ready("s", "wss://x", 3, json!({ "session_id": "s", "guilds": [] }));
first.dms(vec![json!({ "id": "9" })]);
first.guild("g1", &json!({ "id": "g1" }));
first.acked(42);
let (position, replay) = in_dir(dir.path()).load("tok", chrono::Utc::now().timestamp()).expect("fresh enough");
assert_eq!((position.session_id.as_str(), position.seq), ("s", 42));
// READY first, then the servers, each marked as saved and numbered where the session had got to.
assert_eq!(replay[0]["t"], "READY");
assert_eq!(replay[0]["d"]["moho_dms"][0]["id"], "9");
assert_eq!(replay[1]["t"], "GUILD_CREATE");
assert!(replay.iter().all(|m| m["s"] == 42 && m["moho_replay"] == true));
}
#[test]
fn a_session_cleared_or_of_another_token_is_not_resumed() {
let dir = Scratch::new("cleared");
let mut file = in_dir(dir.path());
file.ready("s", "wss://x", 1, json!({}));
file.acked(2);
let now = chrono::Utc::now().timestamp();
assert!(in_dir(dir.path()).load("other", now).is_none());
file.clear();
assert!(in_dir(dir.path()).load("tok", now).is_none());
}
#[test]
fn a_session_that_was_never_acknowledged_is_not_saved() {
let dir = Scratch::new("never_acked");
let mut file = in_dir(dir.path());
file.ready("s", "wss://x", 1, json!({}));
assert!(in_dir(dir.path()).load("tok", chrono::Utc::now().timestamp()).is_none());
}
fn pos(acked_at: i64) -> Position {
Position { user_id: "7".into(), token: token_hash("tok"), session_id: "s".into(), url: "wss://x".into(), seq: 3, acked_at }
}
#[test]
fn a_session_is_resumed_only_while_its_last_ack_is_fresh() {
assert!(resumable(&pos(1000), "7", "tok", 1000 + FRESH_FOR - 1));
assert!(!resumable(&pos(1000), "7", "tok", 1000 + FRESH_FOR));
assert!(!resumable(&pos(1000), "7", "tok", 1000 + 3600));
}
#[test]
fn a_session_belongs_to_one_account_and_one_token() {
assert!(!resumable(&pos(1000), "8", "tok", 1010));
assert!(!resumable(&pos(1000), "7", "other", 1010));
// A clock set back is not a fresh session.
assert!(!resumable(&pos(5000), "7", "tok", 1000));
}
#[test]
fn the_token_itself_is_not_what_is_written() {
assert!(!token_hash("tok").contains("tok"));
assert_eq!(token_hash("tok"), token_hash("tok"));
assert_ne!(token_hash("tok"), token_hash("tok2"));
}
}
+2 -2
View File
@@ -95,7 +95,7 @@ async fn default_sounds(token: &str) -> Vec<Sound> {
let resp = http_client_for(token)
.get(format!("{API_BASE}/soundboard-default-sounds"))
.header("Authorization", token)
.send()
.send_paced()
.await
.ok()?;
resp.status().is_success().then_some(())?;
@@ -229,7 +229,7 @@ async fn fetch(token: &str, id: &str) -> Result<Vec<u8>> {
}
let resp = http_client_for(token)
.get(format!("https://cdn.discordapp.com/soundboard-sounds/{id}"))
.send()
.send_paced()
.await
.context("fetching the sound")?;
if !resp.status().is_success() {
+9 -3
View File
@@ -74,7 +74,7 @@ pub async fn lottie_file(token: &str, id: &str) -> Result<String> {
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
let resp = http_client_for(token)
.get(lottie_url(id))
.send()
.send_paced()
.await
.context("fetching the sticker")?;
if !resp.status().is_success() {
@@ -157,7 +157,7 @@ async fn standard_packs(token: &str) -> Vec<(String, Vec<Sticker>)> {
let resp = http_client_for(token)
.get(format!("{API_BASE}/sticker-packs"))
.header("Authorization", token)
.send()
.send_paced()
.await
.ok()?;
if !resp.status().is_success() {
@@ -241,11 +241,16 @@ fn guild_list(state: &AppState, account_id: &str, buffer_id: &str) -> Vec<Value>
/// Sends a sticker as a message of its own.
pub async fn send(state: &AppState, buffer_id: &str, token: &str, sticker_id: &str) -> Result<()> {
let channel_id = state.runtime.get_discord_channel(buffer_id).ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
super::send_guard::ensure_may_speak(state, buffer_id)?;
let resp = send_write(
http_client_for(token)
.post(format!("{API_BASE}/channels/{channel_id}/messages"))
.header("Authorization", token)
.json(&json!({ "sticker_ids": [sticker_id] })),
.json(&{
let mut payload = json!({ "sticker_ids": [sticker_id] });
super::send_guard::stamp(&mut payload);
payload
}),
)
.await
.context("sending a Discord sticker")?;
@@ -254,6 +259,7 @@ pub async fn send(state: &AppState, buffer_id: &str, token: &str, sticker_id: &s
let text = resp.text().await.unwrap_or_default();
bail!("Discord API error {status}: {text}");
}
super::send_guard::note_sent(resp).await;
Ok(())
}
+1
View File
@@ -589,6 +589,7 @@ pub(super) fn reply_preview(metadata: Option<&ReplyMetadata>, thread_parent: Opt
thread: false,
forwarded: false,
thread_root: Some(thread_root),
chat: None,
})
}
+1 -1
View File
@@ -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 }),
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 }),
_ => Some(crate::model::ReplyPreview { id: target.to_string(), thread: in_thread, ..Default::default() }),
}
}
+1
View File
@@ -16,4 +16,5 @@ pub mod kick;
pub mod matrix;
pub mod sneedchat;
pub mod steam;
pub mod telegram;
pub mod twitch;
+94 -14
View File
@@ -27,19 +27,75 @@ use base64::Engine;
use rsa::{BigUint, Pkcs1v15Encrypt, RsaPublicKey};
use std::time::Duration;
/// What moho signs in as: the Steam mobile app's chat session.
/// What moho signs in as: a desktop Steam client, the way every other third-party client does.
///
/// 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.
/// 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.
///
/// `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";
/// `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())
}
/// `EOSType` as the connection reports it: Linux 6.x is -184 and Windows 10
/// is 16, sent as the unsigned number the field is.
@@ -155,7 +211,9 @@ fn device_details() -> DeviceDetails {
device_friendly_name: Some(device_name()),
platform_type: Some(PLATFORM_TYPE),
os_type: Some(os_type() as i32),
app_type: Some(APP_TYPE),
gaming_device_type: Some(GAMING_DEVICE_TYPE),
// Sent with a password sign-in, as the desktop client's is.
machine_id: Some(machine_id()),
..DeviceDetails::default()
}
}
@@ -433,8 +491,11 @@ 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(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_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!(!begin_req.encrypted_password.unwrap().contains("hunter2"));
let code_req = UpdateAuthWithGuardCodeRequest::decode(seen[2].body.as_slice()).unwrap();
@@ -519,4 +580,23 @@ 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);
}
}
+4 -3
View File
@@ -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(3);
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(60);
const RECONNECT_INITIAL_DELAY: Duration = Duration::from_secs(10);
const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(300);
/// 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,10 +124,11 @@ 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(0)) }),
obfuscated_private_ip: Some(IpAddress { ip: Some(ip_address::Ip::V4(auth::logon_id())) }),
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()),
+1 -1
View File
@@ -305,7 +305,7 @@ pub async fn flow(
display_name: None,
refresh_token: tokens.refresh_token,
guard_data: tokens.guard_data,
// Always the mobile chat session; kept so older files still read.
// The kind is no longer chosen; kept so older files still read.
session: "chat".to_string(),
use_tor: crate::net::route::router().wanted(&key),
strict_route: false,
+46
View File
@@ -0,0 +1,46 @@
//! 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()),
}
}
+96
View File
@@ -0,0 +1,96 @@
//! What a bot offers: the commands in its menu.
//!
//! Telegram keeps a bot's list of commands (`/help`, `/weblink`, ...) with the bot, and its own apps show it as a
//! Menu button beside the message box and as suggestions while typing a slash. It is read once from the bot's profile
//! and kept for a while, since it changes when the bot's author changes it and not otherwise.
use super::send::{explain, peer_for};
use crate::model::Buffer;
use crate::state::AppState;
use grammers_client::tl;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// How long a bot's list is kept before it is asked for again.
const KEEP: Duration = Duration::from_secs(15 * 60);
/// One command in a bot's menu.
#[derive(Clone, Debug, PartialEq)]
pub struct Command {
pub name: String,
pub description: String,
}
/// When each conversation's list was read, and what it said.
type Kept = Mutex<HashMap<String, (Instant, Vec<Command>)>>;
fn kept() -> &'static Kept {
static K: std::sync::OnceLock<Kept> = std::sync::OnceLock::new();
K.get_or_init(Default::default)
}
/// The commands a bot's profile lists.
fn from_info(info: &tl::enums::BotInfo) -> Vec<Command> {
let tl::enums::BotInfo::Info(info) = info;
info.commands
.iter()
.flatten()
.map(|c| {
let tl::enums::BotCommand::Command(c) = c;
Command { name: c.command.trim_start_matches('/').to_string(), description: c.description.clone() }
})
.filter(|c| !c.name.is_empty())
.collect()
}
/// The commands of the bot a conversation is with; nothing for a conversation that is not with a bot.
pub async fn commands(state: &AppState, buffer: &Buffer) -> Result<Vec<Command>, String> {
if buffer.kind != "dm" || buffer.name_icon.as_deref() != Some("smart_toy") {
return Ok(Vec::new());
}
if let Some((when, list)) = kept().lock().unwrap_or_else(|e| e.into_inner()).get(&buffer.id) {
if when.elapsed() < KEEP {
return Ok(list.clone());
}
}
let (handle, peer) = peer_for(state, buffer).await?;
let answer = handle.client.invoke(&tl::functions::users::GetFullUser { id: peer.into() }).await.map_err(|e| explain(&e))?;
let tl::enums::users::UserFull::Full(full) = answer;
let tl::enums::UserFull::Full(user) = full.full_user;
let list = user.bot_info.as_ref().map(from_info).unwrap_or_default();
kept().lock().unwrap_or_else(|e| e.into_inner()).insert(buffer.id.clone(), (Instant::now(), list.clone()));
Ok(list)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bots_menu_is_read_from_its_profile() {
let info = tl::enums::BotInfo::Info(tl::types::BotInfo {
has_preview_medias: false,
user_id: Some(1),
description: None,
description_photo: None,
description_document: None,
commands: Some(vec![
tl::enums::BotCommand::Command(tl::types::BotCommand { ephemeral: false, command: "help".into(), description: "Show help for bot commands".into() }),
tl::enums::BotCommand::Command(tl::types::BotCommand { ephemeral: false, command: "/weblink".into(), description: "Get available link to ZLibrary".into() }),
tl::enums::BotCommand::Command(tl::types::BotCommand { ephemeral: false, command: "".into(), description: "nothing".into() }),
]),
menu_button: None,
privacy_policy_url: None,
app_settings: None,
verifier_settings: None,
});
assert_eq!(
from_info(&info),
vec![
Command { name: "help".into(), description: "Show help for bot commands".into() },
Command { name: "weblink".into(), description: "Get available link to ZLibrary".into() },
]
);
}
}
+295
View File
@@ -0,0 +1,295 @@
//! 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 inner: &grammers_client::message::Message = &message;
let buffer = super::topics::buffer_of_message(state, account_id, &peer, &inner.raw);
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);
// A bot changes or removes its buttons by editing the message they are under.
super::keyboards::note(state, &buffer.id, &message.id().to_string(), &inner.raw, true);
}
}
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()) {
// A group with topics keeps each topic's conversation under the group's name and a colon.
(Some(c), Some(remote)) => {
let channel = peers::remote_id(grammers_session::types::PeerId::channel_unchecked(c));
remote == channel || remote.strip_prefix(channel.as_str()).is_some_and(|rest| rest.starts_with(':'))
}
(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);
}
}
+70
View File
@@ -0,0 +1,70 @@
//! 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 };
// A group with Topics is a conversation for each topic, found by asking the group.
if super::topics::is_forum(dialog.peer()) {
peers::rail_for(state, account_id, dialog.peer(), count as i64);
if let Err(e) = super::topics::sync(state, account_id, handle, dialog.peer()).await {
tracing::warn!("telegram[{account_id}]: reading a group's topics: {e:#}");
}
if let Some(last) = &dialog.last_message {
messages::record_named(state, account_id, &handle.own, last, Some(&handle.names));
}
count += 1;
continue;
}
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.
// A group is its own entry on the rail and has no heading in the account's list.
if peers::is_group(dialog.peer()) {
peers::file_in_rail(state, account_id, &buffer, dialog.peer(), count as i64);
} else {
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)
}
+197
View File
@@ -0,0 +1,197 @@
//! GIFs: the account's saved ones, and a search through Telegram's own GIF bot.
//!
//! Telegram does not send a GIF as a link: a GIF is a short silent video that lives on Telegram, and sending one is
//! sending that file. The account keeps a list of saved GIFs, and the search is the inline bot `@gif`, which answers
//! with files of the same kind. Both are listed as the picker's cells; the pictures and the videos are named in the
//! cache and fetched when the picker draws them, from the files remembered here (a file is fetched with its access
//! key and a reference that goes stale, so what was listed is what can be fetched).
use super::media::{download_thumb, download_to, extension_of, MEDIA};
use super::messages;
use super::send::{explain, peer_for};
use super::Handle;
use crate::model::Buffer;
use crate::state::AppState;
use anyhow::{anyhow, Context, Result};
use grammers_client::media::Document;
use grammers_client::message::InputMessage;
use grammers_client::tl;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// How many GIFs a list shows.
const LIMIT: usize = 100;
/// The documents that were listed, by (account, id): what the picker names is what can be fetched or sent.
type Known = Mutex<HashMap<(i64, i64), tl::types::Document>>;
fn known() -> &'static Known {
static K: std::sync::OnceLock<Known> = std::sync::OnceLock::new();
K.get_or_init(Default::default)
}
/// A GIF's cached file: `tgif_<account>_<id>_<thumb|full>.<ext>`.
fn name(account: i64, id: i64, thumb: bool, ext: &str) -> String {
format!("tgif_{account}_{id}_{}.{ext}", if thumb { "thumb" } else { "full" })
}
/// What a `tgif_` name says: the account, the document and which of the two files it is.
fn parse_name(file: &str) -> Option<(i64, i64, bool)> {
let rest = file.strip_prefix("tgif_")?;
let stem = rest.rsplit_once('.')?.0;
let mut parts = stem.split('_');
let account = parts.next()?.parse().ok()?;
let id = parts.next()?.parse().ok()?;
let thumb = match parts.next()? {
"thumb" => true,
"full" => false,
_ => return None,
};
parts.next().is_none().then_some((account, id, thumb))
}
fn dimensions(doc: &tl::types::Document) -> (i32, i32) {
doc.attributes
.iter()
.find_map(|a| match a {
tl::enums::DocumentAttribute::Video(v) => Some((v.w, v.h)),
tl::enums::DocumentAttribute::ImageSize(s) => Some((s.w, s.h)),
_ => None,
})
.unwrap_or((0, 0))
}
fn title_of(doc: &tl::types::Document) -> String {
doc.attributes
.iter()
.find_map(|a| match a {
tl::enums::DocumentAttribute::Filename(f) => Some(f.file_name.clone()),
_ => None,
})
.unwrap_or_else(|| "GIF".to_string())
}
fn wrapper(doc: &tl::types::Document) -> Document {
Document::from_raw_media(tl::types::MessageMediaDocument {
nopremium: false,
spoiler: false,
video: true,
round: false,
voice: false,
document: Some(tl::enums::Document::Document(doc.clone())),
alt_documents: None,
video_cover: None,
video_timestamp: None,
ttl_seconds: None,
})
}
/// A listed document as the picker's cell: its files named in the cache, fetched when drawn.
fn entry(account: i64, doc: &tl::types::Document) -> Value {
let (w, h) = dimensions(doc);
let full = format!("file://{}", crate::media_cache::dir(MEDIA).join(name(account, doc.id, false, &extension_of(None, Some(&doc.mime_type)))).display());
let thumb = format!("file://{}", crate::media_cache::dir(MEDIA).join(name(account, doc.id, true, "jpg")).display());
json!({ "id": doc.id.to_string(), "title": title_of(doc), "url": "", "src": full, "gifSrc": full, "preview": thumb, "width": w, "height": h })
}
fn remember(account: i64, docs: Vec<tl::types::Document>) -> Vec<Value> {
let mut out = Vec::new();
let mut held = known().lock().unwrap_or_else(|e| e.into_inner());
for doc in docs.into_iter().take(LIMIT) {
out.push(entry(account, &doc));
held.insert((account, doc.id), doc);
}
out
}
fn documents(list: Vec<tl::enums::Document>) -> Vec<tl::types::Document> {
list.into_iter()
.filter_map(|d| match d {
tl::enums::Document::Document(d) => Some(d),
_ => None,
})
.collect()
}
/// What the picker shows: the saved GIFs when nothing is typed, else what Telegram's GIF search finds.
pub async fn list(state: &AppState, buffer: &Buffer, query: &str) -> Result<Value, String> {
let (handle, peer) = peer_for(state, buffer).await?;
let account = handle.own.user_id;
let docs = if query.trim().is_empty() {
let answer = handle.client.invoke(&tl::functions::messages::GetSavedGifs { hash: 0 }).await.map_err(|e| explain(&e))?;
match answer {
tl::enums::messages::SavedGifs::Gifs(g) => documents(g.gifs),
tl::enums::messages::SavedGifs::NotModified => Vec::new(),
}
} else {
search(&handle, peer, query.trim()).await.map_err(|e| e.to_string())?
};
Ok(json!({ "gifs": remember(account, docs), "categories": [] }))
}
async fn search(handle: &Handle, peer: grammers_session::types::PeerRef, query: &str) -> Result<Vec<tl::types::Document>> {
let bot = handle.client.resolve_username("gif").await.map_err(|e| anyhow!("{}", explain(&e)))?.ok_or_else(|| anyhow!("Telegram's GIF search is not available"))?;
let bot_ref = bot.to_ref().await.ok().flatten().ok_or_else(|| anyhow!("Telegram's GIF search is not available"))?;
let answer = handle
.client
.invoke(&tl::functions::messages::GetInlineBotResults { bot: bot_ref.into(), peer: peer.into(), geo_point: None, query: query.to_string(), offset: String::new() })
.await
.map_err(|e| anyhow!("{}", explain(&e)))?;
let tl::enums::messages::BotResults::Results(found) = answer;
Ok(found
.results
.into_iter()
.filter_map(|r| match r {
tl::enums::BotInlineResult::BotInlineMediaResult(m) => m.document,
_ => None,
})
.filter_map(|d| match d {
tl::enums::Document::Document(d) => Some(d),
_ => None,
})
.collect())
}
/// Sends a GIF that was listed, to a conversation.
pub async fn send(state: &AppState, buffer: &Buffer, id: &str) -> Result<(), String> {
let id: i64 = id.parse().map_err(|_| "that is not a GIF".to_string())?;
let (handle, peer) = peer_for(state, buffer).await?;
let doc = known().lock().unwrap_or_else(|e| e.into_inner()).get(&(handle.own.user_id, id)).cloned().ok_or_else(|| "That GIF is no longer listed: open the picker again".to_string())?;
let media = wrapper(&doc).to_raw_input_media();
// In a topic of a group the GIF answers the topic's first message, which is how it is sent there.
let reply = super::topics::topic_of_buffer(buffer);
let sent = handle.client.send_message(peer, InputMessage::new().media(tl::enums::InputMedia::Document(media)).reply_to(reply)).await.map_err(|e| explain(&e))?;
messages::record(state, &buffer.account_id, &handle.own, &sent);
Ok(())
}
/// Fetches one of a listed GIF's files again, for the window asking for it.
pub async fn restore(state: &AppState, file: &str, path: &Path) -> Result<Option<PathBuf>> {
let Some((account, id, thumb)) = parse_name(file) else { return Ok(None) };
let handle = state.runtime.telegram_handle(&format!("telegram:{account}")).context("that Telegram account is not connected")?;
let doc = known().lock().unwrap_or_else(|e| e.into_inner()).get(&(account, id)).cloned().context("that GIF is no longer listed")?;
let wrapped = wrapper(&doc);
if thumb {
download_thumb(&handle.client, &wrapped, path).await?;
} else {
download_to(&handle.client, &wrapped, path, false).await?;
}
Ok(Some(path.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_gifs_files_are_named_and_read_back() {
let video = name(1234567890, 5_000_000_000_001, false, "mp4");
assert_eq!(video, "tgif_1234567890_5000000000001_full.mp4");
assert_eq!(parse_name(&video), Some((1234567890, 5_000_000_000_001, false)));
assert_eq!(parse_name(&name(1, 7, true, "jpg")), Some((1, 7, true)));
assert_eq!(parse_name("tg_1_n5_2_full.jpg"), None);
assert_eq!(parse_name("tgif_1_2_other.jpg"), None);
}
}
+152
View File
@@ -0,0 +1,152 @@
//! 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 super::topics;
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;
/// A page of one topic's messages, as the messages themselves. Telegram lists a topic's messages as raw ones, so the
/// ids are taken from that answer and the messages asked for by id: the library hands those over whole.
async fn topic_page(
handle: &super::Handle,
peer: grammers_session::types::PeerRef,
topic: i32,
offset_id: i32,
offset_date: i32,
add_offset: i32,
limit: i32,
) -> Result<Vec<grammers_client::message::Message>> {
use grammers_client::tl;
let answer = handle
.client
.invoke(&tl::functions::messages::GetReplies { peer: peer.into(), msg_id: topic, offset_id, offset_date, add_offset, limit, max_id: 0, min_id: 0, hash: 0 })
.await
.map_err(|e| anyhow!("{}", super::send::explain(&e)))?;
let listed = match answer {
tl::enums::messages::Messages::Messages(m) => m.messages,
tl::enums::messages::Messages::Slice(m) => m.messages,
tl::enums::messages::Messages::ChannelMessages(m) => m.messages,
tl::enums::messages::Messages::NotModified(_) => Vec::new(),
};
let mut ids: Vec<i32> = listed
.iter()
.filter_map(|m| match m {
tl::enums::Message::Message(m) => Some(m.id),
tl::enums::Message::Service(m) => Some(m.id),
tl::enums::Message::Empty(_) => None,
})
.collect();
ids.sort_unstable();
ids.dedup();
if ids.is_empty() {
return Ok(Vec::new());
}
let found = handle.client.get_messages_by_id(peer, &ids).await.map_err(|e| anyhow!("{}", super::send::explain(&e)))?;
Ok(found.into_iter().flatten().collect())
}
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"))?;
if let Some(topic) = topics::topic_of_buffer(buffer) {
let date = before.filter(|t| *t > 0).map(|t| t.clamp(0, i64::from(i32::MAX)) as i32).unwrap_or(0);
let got = topic_page(&handle, peer, topic, 0, date, 0, PAGE as i32).await?;
let count = got.len();
for message in &got {
super::messages::record(state, &buffer.account_id, &handle.own, message);
}
return Ok(count);
}
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"))?;
if let Some(topic) = topics::topic_of_buffer(buffer) {
let mut around = topic_page(&handle, peer, topic, id.saturating_add(1), 0, 0, 26).await?;
around.extend(topic_page(&handle, peer, topic, id, 0, -25, 25).await?);
around.sort_by_key(|m| m.id());
around.dedup_by_key(|m| m.id());
for message in &around {
super::messages::record(state, &buffer.account_id, &handle.own, message);
}
return around.iter().find(|m| m.id() == id).map(|m| m.date().as_second()).ok_or_else(|| anyhow!("that message is no longer there"));
}
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"))?;
if let Some(topic) = topics::topic_of_buffer(buffer) {
let got = topic_page(&handle, peer, topic, id, 0, -(PAGE as i32), PAGE as i32).await?;
for message in got.iter().filter(|m| m.id() > id) {
super::messages::record(state, &buffer.account_id, &handle.own, message);
}
return Ok(got.iter().filter(|m| m.id() > id).count());
}
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)
}
+447
View File
@@ -0,0 +1,447 @@
//! 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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'").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 &lt;b&gt; &amp; <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&amp;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)]);
}
}
+162
View File
@@ -0,0 +1,162 @@
//! A bot's inline keyboard: the buttons under one of its messages.
//!
//! Telegram sends them as rows of buttons with the message. Most are "callback" buttons, which tell the bot something
//! when pressed and are answered with a short notice (or nothing, the bot editing its message instead); the rest are
//! links. They are drawn with the same shape Discord's buttons use, and an edit to the message that changes or removes
//! them says so.
use super::send::{explain, peer_for};
use crate::model::{Buffer, Component};
use crate::state::AppState;
use grammers_client::tl;
use serde_json::json;
use tl::enums::InlineButtonType as T;
fn hex(data: &[u8]) -> String {
data.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(text: &str) -> Option<Vec<u8>> {
if !text.len().is_multiple_of(2) {
return None;
}
(0..text.len()).step_by(2).map(|i| u8::from_str_radix(text.get(i..i + 2)?, 16).ok()).collect()
}
/// Only an address a window may open in a browser.
fn web(url: &str) -> Option<String> {
let lower = url.trim().to_ascii_lowercase();
(lower.starts_with("https://") || lower.starts_with("http://")).then(|| url.trim().to_string())
}
/// The buttons of an inline keyboard, in rows, as this client draws buttons. Nothing for any other kind of markup.
pub fn components_of(markup: &tl::enums::ReplyMarkup) -> Vec<Component> {
let tl::enums::ReplyMarkup::ReplyInlineMarkup(inline) = markup else { return Vec::new() };
let mut out = Vec::new();
for (row_index, row) in inline.rows.iter().enumerate() {
let tl::enums::KeyboardInlineButtonRow::Row(row) = row;
for button in &row.buttons {
let tl::enums::KeyboardInlineButton::Button(button) = button;
let style = button.style.as_ref().map(|s| {
let tl::enums::KeyboardButtonStyle::Style(s) = s;
if s.bg_danger {
"danger"
} else if s.bg_success {
"success"
} else if s.bg_primary {
"primary"
} else {
"secondary"
}
});
let make = |custom_id: Option<String>, style: Option<&str>, url: Option<String>, disabled: bool| Component {
kind: "button".to_string(),
custom_id,
label: Some(button.text.clone()).filter(|t| !t.is_empty()),
style: style.map(str::to_string),
url,
disabled,
emoji: None,
options: Vec::new(),
placeholder: None,
row: row_index as i64,
};
match &button.r#type {
T::Callback(c) => out.push(make(Some(hex(&c.data)), style.or(Some("secondary")), None, c.requires_password)),
T::Url(u) => out.extend(web(&u.url).map(|url| make(None, Some("link"), Some(url), false))),
T::UrlAuth(u) => out.extend(web(&u.url).map(|url| make(None, Some("link"), Some(url), false))),
T::WebView(w) => out.extend(web(&w.url).map(|url| make(None, Some("link"), Some(url), false))),
T::Disabled => out.push(make(None, Some("secondary"), None, true)),
// Switching to inline mode, games, payments, profile links and copy buttons are not done here.
_ => {}
}
}
}
out
}
/// Keeps the buttons on a message and says so. With none, an edit that took them away takes them off the row too.
pub fn note(state: &AppState, buffer_id: &str, msg_id: &str, raw: &tl::enums::Message, edited: bool) {
let tl::enums::Message::Message(m) = raw else { return };
let components = m.reply_markup.as_ref().map(components_of).unwrap_or_default();
// Said when a message has markup that gave no buttons, so what the bot sent is on record rather than guessed at.
if let Some(markup) = m.reply_markup.as_ref().filter(|_| components.is_empty()) {
let text = format!("{markup:?}");
tracing::info!("telegram: message {msg_id} carries markup with no buttons this client draws: {}", text.chars().take(400).collect::<String>());
}
if components.is_empty() && !edited {
return;
}
let kept = if components.is_empty() { state.store.clear_components(buffer_id, msg_id) } else { state.store.set_components(buffer_id, msg_id, &components) };
match kept {
Ok(()) => state.events.emit("messageComponents", json!({ "bufferId": buffer_id, "messageId": msg_id, "components": components })),
Err(e) => tracing::debug!("telegram: keeping the buttons on {msg_id}: {e:#}"),
}
}
/// What the bot said back to a press: a notice to show (as a pop-up alert when it asked for one), and an address to open.
pub async fn press(state: &AppState, buffer: &Buffer, msg_id: &str, custom_id: &str) -> Result<serde_json::Value, String> {
let id: i32 = msg_id.parse().map_err(|_| "that message has no buttons".to_string())?;
let data = unhex(custom_id).ok_or_else(|| "that button is not one this client can press".to_string())?;
let (handle, peer) = peer_for(state, buffer).await?;
let answer = handle
.client
.invoke(&tl::functions::messages::GetBotCallbackAnswer { game: false, peer: peer.into(), msg_id: id, data: Some(data), password: None })
.await
.map_err(|e| match &e {
grammers_client::InvocationError::Rpc(r) if r.name == "BOT_RESPONSE_TIMEOUT" => "The bot did not answer".to_string(),
grammers_client::InvocationError::Rpc(r) if r.name == "DATA_INVALID" || r.name == "BUTTON_DATA_INVALID" => "That button is no longer valid".to_string(),
_ => explain(&e),
})?;
let tl::enums::messages::BotCallbackAnswer::Answer(a) = answer;
Ok(json!({ "message": a.message, "alert": a.alert, "url": a.url.as_deref().and_then(web) }))
}
#[cfg(test)]
mod tests {
use super::*;
fn button(text: &str, kind: T) -> tl::enums::KeyboardInlineButton {
tl::enums::KeyboardInlineButton::Button(tl::types::KeyboardInlineButton { style: None, text: text.into(), r#type: kind })
}
fn keyboard(rows: Vec<Vec<tl::enums::KeyboardInlineButton>>) -> tl::enums::ReplyMarkup {
tl::enums::ReplyMarkup::ReplyInlineMarkup(tl::types::ReplyInlineMarkup {
force_reply: false,
rows: rows.into_iter().map(|buttons| tl::enums::KeyboardInlineButtonRow::Row(tl::types::KeyboardInlineButtonRow { buttons })).collect(),
})
}
#[test]
fn data_survives_the_trip_through_a_button() {
assert_eq!(unhex(&hex(&[0, 1, 0xab, 0xff])), Some(vec![0, 1, 0xab, 0xff]));
assert_eq!(unhex("abc"), None);
assert_eq!(unhex("zz"), None);
}
#[test]
fn rows_keep_their_places_and_links_are_links() {
let markup = keyboard(vec![
vec![button("Next", T::Callback(tl::types::InlineButtonTypeCallback { requires_password: false, data: b"next:2".to_vec() })), button("Close", T::Callback(tl::types::InlineButtonTypeCallback { requires_password: false, data: b"x".to_vec() }))],
vec![button("Open site", T::Url(tl::types::InlineButtonTypeUrl { url: "https://example.com/a".into() }))],
]);
let got = components_of(&markup);
assert_eq!(got.iter().map(|c| (c.label.as_deref().unwrap(), c.row)).collect::<Vec<_>>(), vec![("Next", 0), ("Close", 0), ("Open site", 1)]);
assert_eq!(got[0].custom_id.as_deref(), Some(hex(b"next:2").as_str()));
assert_eq!(got[2].url.as_deref(), Some("https://example.com/a"));
assert_eq!(got[2].style.as_deref(), Some("link"));
}
#[test]
fn what_cannot_be_opened_or_pressed_is_left_out() {
let markup = keyboard(vec![vec![
button("App", T::Url(tl::types::InlineButtonTypeUrl { url: "tg://resolve?domain=x".into() })),
button("Pay", T::Buy),
button("Locked", T::Callback(tl::types::InlineButtonTypeCallback { requires_password: true, data: b"x".to_vec() })),
]]);
let got = components_of(&markup);
assert_eq!(got.len(), 1, "only the callback is kept, and it cannot be pressed");
assert!(got[0].disabled);
assert!(components_of(&tl::enums::ReplyMarkup::ReplyKeyboardHide(tl::types::ReplyKeyboardHide { selective: false })).is_empty());
}
}
+49
View File
@@ -0,0 +1,49 @@
//! 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:#}");
}
}
}
+292
View File
@@ -0,0 +1,292 @@
//! 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();
}
}
+284
View File
@@ -0,0 +1,284 @@
//! 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)]);
}
}
+486
View File
@@ -0,0 +1,486 @@
//! 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());
}
// A GIF the picker listed is fetched from what the picker remembered of it.
if name.starts_with("tgif_") {
return super::gifs::restore(state, name, path).await?.context("not a cached Telegram GIF");
}
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(1234567890, "-1001234567890", 21910, Variant::Full, "mp4");
assert_eq!(name, "tg_1234567890_n1001234567890_21910_full.mp4");
assert_eq!(parse_media_name(&name), Some((1234567890, "-1001234567890".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);
}
}
+59
View File
@@ -0,0 +1,59 @@
//! 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)
}
+134
View File
@@ -0,0 +1,134 @@
//! 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);
}
}
+487
View File
@@ -0,0 +1,487 @@
//! 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// 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 {
// A picture, a video, a file, a voice message or a sticker is drawn from its attachment: saying it again in
// words (with the file's name) is noise. Everything else with no picture of its own keeps its label.
let drawn = matches!(m.media, Some(tl::enums::MessageMedia::Photo(_)) | Some(tl::enums::MessageMedia::Document(_)));
if let Some(label) = m.media.as_ref().filter(|_| !drawn).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 = super::topics::buffer_of_message(state, account_id, peer, &message.raw);
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();
// A post in a channel has no sender but the channel, whose picture it is.
let avatar = if outgoing { None } else { sender.or(Some(peer)).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 kept with a label for its picture in its text (earlier builds wrote "[Photo]") loses it.
if !recorded {
if let Ok(Some(stored)) = state.store.get_message(&buffer.id, &id) {
let clean = body.trim_end();
if stored.body != body && stored.body.starts_with(clean) && stored.body[clean.len()..].trim_start().starts_with('[') && !again.is_empty() {
state.runtime.update_message_body_only(state, &buffer.id, &id, &body);
}
}
}
// 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 }));
}
}
}
// The buttons a bot put under it.
super::keyboards::note(state, &buffer.id, &id, &message.raw, false);
// 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| if m.body.trim().is_empty() { attachment_words(&m.attachments) } else { m.body })).filter(|b| !b.is_empty()).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) })
}
/// What a message with only a picture or a file says in a quote, from what it carries.
fn attachment_words(attachments: &[crate::model::Attachment]) -> String {
let Some(first) = attachments.first() else { return String::new() };
match (first.kind.as_str(), first.voice) {
(_, true) => "Voice message".to_string(),
("image", _) => "Photo".to_string(),
("video", _) => "Video".to_string(),
("audio", _) => "Audio".to_string(),
_ => first.filename.clone().unwrap_or_else(|| "File".to_string()),
}
}
/// 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> &lt; 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&amp;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);
}
}
+123
View File
@@ -0,0 +1,123 @@
//! 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
//! - `keyboards` - a bot's inline buttons
//! - `bots` - a bot's command menu
//! - `topics` - a group with Topics as several conversations
//! - `gifs` - saved GIFs and the GIF search
//! - `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 bots;
pub mod connect;
pub mod dialogs;
pub mod gifs;
pub mod history;
pub mod instant;
pub mod keyboards;
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 topics;
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());
}
}
+77
View File
@@ -0,0 +1,77 @@
//! 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));
}
}
+312
View File
@@ -0,0 +1,312 @@
//! 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> {
// A topic's conversation names its group first and the topic after a colon.
PeerId::from_bot_api_dialog_id(remote_id.split(':').next()?.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,
}
}
/// Whether a conversation is a group (small, or a supergroup): each has an entry of its own on the rail, as a server does.
pub fn is_group(peer: &Peer) -> bool {
matches!(peer, Peer::Group(_))
}
/// The rail entry of a group.
pub fn rail_id(account_id: &str, remote: &str) -> String {
format!("{account_id}|tggroup:{remote}")
}
/// Gives a group its place on the rail, with its own name and picture. Answers its rail id; nothing for anything that
/// is not a group.
pub fn rail_for(state: &AppState, account_id: &str, peer: &Peer, position: i64) -> Option<String> {
if !is_group(peer) {
return None;
}
let remote = remote_id(peer.id());
let rail = rail_id(account_id, &remote);
let account: i64 = account_id.strip_prefix("telegram:").and_then(|a| a.parse().ok()).unwrap_or(0);
state.runtime.upsert_buffer_group(
state,
crate::model::BufferGroup {
id: rail.clone(),
account_id: account_id.to_string(),
service: "telegram".to_string(),
kind: "guild".to_string(),
name: title_of(peer),
icon_url: super::media::avatar_url(account, peer),
position,
pending: false,
},
);
Some(rail)
}
/// Gives a group its place on the rail and files its conversation under it. Nothing for anything that is not a group:
/// people, bots and channels stay in the account's own list.
pub fn file_in_rail(state: &AppState, account_id: &str, buffer: &Buffer, peer: &Peer, position: i64) {
let Some(rail) = rail_for(state, account_id, peer, position) else { return };
state.runtime.set_buffer_group(state, &buffer.id, &rail);
state.runtime.set_buffer_category(state, &buffer.id, None, 0);
}
/// 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));
file_in_rail(state, account_id, &buffer, peer, 1000);
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(1234567890);
assert_eq!(remote_id(user), "1234567890");
assert_eq!(peer_id_of("1234567890"), 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);
}
}
+49
View File
@@ -0,0 +1,49 @@
//! 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(())
}
+280
View File
@@ -0,0 +1,280 @@
//! 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);
}
}
+111
View File
@@ -0,0 +1,111 @@
//! 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));
}
}
+138
View File
@@ -0,0 +1,138 @@
//! 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) = super::topics::holding(state, account_id, remote, msg_id) 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("👍")]);
}
}
+56
View File
@@ -0,0 +1,56 @@
//! 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 }))
}
+380
View File
@@ -0,0 +1,380 @@
//! 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> {
if let Some(topic) = super::topics::topic_of_buffer(buffer) {
return send_in_topic(state, buffer, body, reply_to, topic).await;
}
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(())
}
/// Sends a message into a forum topic. An answer to a message in a topic has to name the topic as well, which the
/// library's own send cannot say, so the request is made directly and the message it made is read back by its id.
async fn send_in_topic(state: &AppState, buffer: &Buffer, body: &str, reply_to: Option<&str>, topic: i32) -> Result<(), String> {
let (text, entities) = checked(body)?;
let (handle, peer) = peer_for(state, buffer).await?;
let reply = reply_to.and_then(|id| id.parse::<i32>().ok()).unwrap_or(topic);
let random_id: i64 = rand::random();
let updates = handle
.client
.invoke(&tl::functions::messages::SendMessage {
no_webpage: false,
silent: false,
background: false,
clear_draft: false,
noforwards: false,
update_stickersets_order: false,
invert_media: false,
allow_paid_floodskip: false,
peer: peer.into(),
reply_to: Some(
tl::types::InputReplyToMessage { reply_to_msg_id: reply, top_msg_id: Some(topic), reply_to_peer_id: None, quote_text: None, quote_entities: None, quote_offset: None, monoforum_peer_id: None, todo_item_id: None, poll_option: None }
.into(),
),
message: text,
random_id,
reply_markup: None,
entities: Some(entities).filter(|e| !e.is_empty()),
schedule_date: None,
schedule_repeat_period: None,
send_as: None,
quick_reply_shortcut: None,
effect: None,
allow_paid_stars: None,
suggested_post: None,
rich_message: None,
})
.await
.map_err(|e| explain(&e))?;
if let Some(id) = sent_id(&updates, random_id) {
if let Ok(found) = handle.client.get_messages_by_id(peer, &[id]).await {
if let Some(Some(sent)) = found.into_iter().next() {
messages::record(state, &buffer.account_id, &handle.own, &sent);
}
}
}
Ok(())
}
/// The id Telegram gave a message that was sent with `random_id`, from what it answered.
fn sent_id(updates: &tl::enums::Updates, random_id: i64) -> Option<i32> {
use tl::enums::Updates as U;
let list: &[tl::enums::Update] = match updates {
U::Updates(u) => &u.updates,
U::Combined(u) => &u.updates,
U::UpdateShort(u) => std::slice::from_ref(&u.update),
U::UpdateShortSentMessage(u) => return Some(u.id),
_ => return None,
};
list.iter().find_map(|u| match u {
tl::enums::Update::MessageId(m) if m.random_id == random_id => Some(m.id),
_ => None,
})
}
/// 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?;
// A topic is read on its own: to the newest message of it that is here.
if let Some(topic) = super::topics::topic_of_buffer(buffer) {
let newest = state.store.newest_message_id(&buffer.id).ok().flatten().and_then(|id| id.parse::<i32>().ok()).unwrap_or(0);
if newest > 0 {
handle.client.invoke(&tl::functions::messages::ReadDiscussion { peer: peer.into(), msg_id: topic, read_max_id: newest }).await.map_err(|e| explain(&e))?;
}
return Ok(());
}
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?;
// In a topic, a message with nothing to answer answers the topic's first message, which is how it is sent there.
let reply = reply_to.and_then(|id| id.parse::<i32>().ok()).or_else(|| super::topics::topic_of_buffer(buffer));
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));
}
}
+277
View File
@@ -0,0 +1,277 @@
//! 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();
}
}
+200
View File
@@ -0,0 +1,200 @@
//! 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) = &copy.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());
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Forum topics: a group with Topics switched on is several conversations, each its own place to read and write.
//!
//! Telegram keeps a topic as a message (its first, the "topic root") that every message in the topic answers; the
//! General topic is number 1. Each topic is a conversation of its own here, filed under its group's entry on the rail,
//! named `Group/#Topic`, and its `remote_id` is the group's with the topic after a colon (`-100123:45`). A message is
//! routed to its topic from what it answers; the topics come from the group when the account connects, and a topic
//! made while moho is running is found when its first message arrives.
use super::peers;
use super::Handle;
use crate::model::Buffer;
use crate::state::AppState;
use anyhow::{anyhow, Result};
use grammers_client::peer::Peer;
use grammers_client::tl;
/// The topic every message with no other belongs to.
pub const GENERAL: i32 = 1;
/// Whether a conversation is a group with Topics switched on.
pub fn is_forum(peer: &Peer) -> bool {
matches!(peer, Peer::Group(g) if matches!(&g.raw, tl::enums::Chat::Channel(c) if c.forum))
}
/// A topic's `remote_id`: its group's, then the topic.
pub fn remote_of(group_remote: &str, topic: i32) -> String {
format!("{group_remote}:{topic}")
}
/// The topic a conversation is, when it is one.
pub fn topic_of_buffer(buffer: &Buffer) -> Option<i32> {
buffer.remote_id.as_deref()?.split_once(':')?.1.parse().ok()
}
fn header_topic(header: &tl::enums::MessageReplyHeader) -> Option<i32> {
let tl::enums::MessageReplyHeader::Header(h) = header else { return None };
if !h.forum_topic {
return None;
}
// A message in a topic answers the topic's first message, or - when it answers another - names the topic as well.
h.reply_to_top_id.or(h.reply_to_msg_id)
}
/// The topic a message was said in.
pub fn topic_of(raw: &tl::enums::Message) -> i32 {
match raw {
tl::enums::Message::Message(m) => m.reply_to.as_ref().and_then(header_topic).unwrap_or(GENERAL),
// The message that makes a topic is the topic's own root.
tl::enums::Message::Service(s) if matches!(s.action, tl::enums::MessageAction::TopicCreate(_)) => s.id,
tl::enums::Message::Service(s) => s.reply_to.as_ref().and_then(header_topic).unwrap_or(GENERAL),
tl::enums::Message::Empty(_) => GENERAL,
}
}
fn display_name(group: &str, title: &str) -> String {
format!("{group}/#{}", title.trim())
}
/// The conversation of one topic, made if it is not there yet. `title` is its name where known; without it a topic
/// other than General is called "topic N" until the group is asked.
pub fn buffer_for_topic(state: &AppState, account_id: &str, peer: &Peer, topic: i32, title: Option<&str>) -> Buffer {
let group_remote = peers::remote_id(peer.id());
let remote = remote_of(&group_remote, topic);
let mine: Vec<Buffer> = state.runtime.list_buffers().into_iter().filter(|b| b.account_id == account_id).collect();
let group_title = peers::title_of(peer);
if let Some(found) = mine.iter().find(|b| b.remote_id.as_deref() == Some(remote.as_str())) {
// A name learned since: the conversation takes it.
if let Some(title) = title {
let wanted = display_name(&group_title, title);
if found.name != wanted && !mine.iter().any(|b| b.name == wanted) {
state.runtime.rename_buffer(state, account_id, &found.name, &wanted, "channel");
if let Some(renamed) = state.runtime.get_buffer(&crate::model::buffer_id(account_id, &wanted)) {
return renamed;
}
}
}
return found.clone();
}
let title_here = match (title, topic) {
(Some(t), _) => t.to_string(),
(None, GENERAL) => "General".to_string(),
(None, n) => format!("topic {n}"),
};
let taken: Vec<(String, Option<String>)> = mine.iter().map(|b| (b.name.clone(), b.remote_id.clone())).collect();
let name = peers::unique_name(&taken, &remote, &display_name(&group_title, &title_here), None);
let buffer = state.runtime.ensure_buffer(state, account_id, &name, "channel");
state.runtime.set_buffer_remote_id(state, &buffer.id, &remote);
peers::file_in_rail(state, account_id, &buffer, peer, 1000);
let now = chrono::Utc::now().timestamp();
state.runtime.set_buffer_read_only(state, &buffer.id, peers::read_only_for(peer, now));
// A topic that arrived with no name: the group says what its topics are called.
if title.is_none() && topic != GENERAL {
if let Some(handle) = state.runtime.telegram_handle(account_id) {
let (state, account_id, peer) = (state.clone(), account_id.to_string(), peer.clone());
tokio::spawn(async move {
if let Err(e) = sync(&state, &account_id, &handle, &peer).await {
tracing::debug!("telegram: reading a group's topics: {e:#}");
}
});
}
}
state.runtime.get_buffer(&buffer.id).unwrap_or(buffer)
}
/// Where a message goes, for a message in any conversation: its topic's, in a group with topics.
pub fn buffer_of_message(state: &AppState, account_id: &str, peer: &Peer, raw: &tl::enums::Message) -> Buffer {
if is_forum(peer) {
// A topic made, named in the message that makes it.
let title = match raw {
tl::enums::Message::Service(s) => match &s.action {
tl::enums::MessageAction::TopicCreate(a) if !a.title_missing => Some(a.title.clone()),
_ => None,
},
_ => None,
};
buffer_for_topic(state, account_id, peer, topic_of(raw), title.as_deref())
} else {
peers::buffer_for(state, account_id, peer)
}
}
/// The conversation that holds a message, given only its group's `remote_id`: the group's own, or - in a group with
/// topics - the topic it was said in.
pub fn holding(state: &AppState, account_id: &str, remote: &str, msg_id: i32) -> Option<Buffer> {
let mine: Vec<Buffer> = state.runtime.list_buffers().into_iter().filter(|b| b.account_id == account_id).collect();
if let Some(exact) = mine.iter().find(|b| b.remote_id.as_deref() == Some(remote)) {
return Some(exact.clone());
}
let prefix = format!("{remote}:");
let topics: Vec<&Buffer> = mine.iter().filter(|b| b.remote_id.as_deref().is_some_and(|r| r.starts_with(&prefix))).collect();
topics.iter().find(|b| state.store.get_message(&b.id, &msg_id.to_string()).ok().flatten().is_some()).or(topics.first()).map(|b| (*b).clone())
}
/// Reads the group's topics and makes a conversation of each, named as the group names it. The one conversation the
/// group had before it had topics is removed: its messages are in the topics now.
pub async fn sync(state: &AppState, account_id: &str, handle: &Handle, peer: &Peer) -> Result<usize> {
use grammers_session::Session;
let group_remote = peers::remote_id(peer.id());
let peer_ref = handle.session.peer_ref(peer.id()).await.ok().flatten().ok_or_else(|| anyhow!("Telegram has not told this account about that group yet"))?;
let answer = handle
.client
.invoke(&tl::functions::messages::GetForumTopics { peer: peer_ref.into(), q: None, offset_date: 0, offset_id: 0, offset_topic: 0, limit: 100 })
.await
.map_err(|e| anyhow!("{}", super::send::explain(&e)))?;
let tl::enums::messages::ForumTopics::Topics(found) = answer;
// Gone first, so the topics are not named around it.
for old in state.runtime.list_buffers() {
if old.account_id == account_id && old.remote_id.as_deref() == Some(group_remote.as_str()) {
state.runtime.remove_buffer(state, &old.id);
}
}
let mut count = 0;
for topic in &found.topics {
let tl::enums::ForumTopic::Topic(topic) = topic else { continue };
let buffer = buffer_for_topic(state, account_id, peer, topic.id, Some(&topic.title));
let now = chrono::Utc::now().timestamp();
let reason = if topic.closed { Some("This topic is closed.") } else { peers::read_only_for(peer, now) };
state.runtime.set_buffer_read_only(state, &buffer.id, reason);
count += 1;
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
fn header(forum_topic: bool, msg: i32, top: Option<i32>) -> tl::enums::MessageReplyHeader {
tl::enums::MessageReplyHeader::Header(tl::types::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_belongs_to_the_topic_it_answers_or_names() {
assert_eq!(header_topic(&header(true, 45, None)), Some(45), "answering the topic's first message is being in it");
assert_eq!(header_topic(&header(true, 99, Some(45))), Some(45), "answering a message names the topic too");
assert_eq!(header_topic(&header(false, 99, None)), None, "an ordinary reply says nothing of topics");
}
#[test]
fn a_topics_conversation_is_named_by_its_group_and_found_by_its_remote_id() {
assert_eq!(remote_of("-1001234", 45), "-1001234:45");
assert_eq!(display_name("Chasm", " Support "), "Chasm/#Support");
assert_eq!(peers::peer_id_of("-1001234:45"), peers::peer_id_of("-1001234"));
}
}
+87
View File
@@ -0,0 +1,87 @@
//! 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();
// In a group with topics somebody types in one of them.
let group = peers::remote_id(PeerId::channel_unchecked(u.channel_id));
let found = buffer_of(state, account_id, &group).or_else(|| buffer_of(state, account_id, &super::topics::remote_of(&group, u.top_msg_id.unwrap_or(super::topics::GENERAL))));
if let (Some(buffer), Some(name)) = (found, 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),
_ => {}
}
}
+1
View File
@@ -345,6 +345,7 @@ pub fn reply_of(line: &Line, text: &str) -> (Option<ReplyPreview>, String) {
thread: false,
forwarded: false,
thread_root: Some(root.to_string()),
chat: None,
}),
body,
)
+1 -1
View File
@@ -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) })
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 })
}
_ => None,
};
+3
View File
@@ -343,6 +343,9 @@ 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.
+9
View File
@@ -38,6 +38,8 @@ 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 },
@@ -209,6 +211,13 @@ 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()))
+13
View File
@@ -379,6 +379,10 @@ 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")]
@@ -482,6 +486,12 @@ 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.
@@ -910,6 +920,7 @@ pub fn service_of(account_id: &str) -> &'static str {
Some("sneedchat") => "sneedchat",
Some("steam") => "steam",
Some("twitch") => "twitch",
Some("telegram") => "telegram",
Some("jabber") => "jabber",
Some("slack") => "slack",
_ => "irc",
@@ -927,6 +938,7 @@ mod service_tests {
assert_eq!(service_of("kick:someone"), "kick");
assert_eq!(service_of("sneedchat:Ancient Pioneer"), "sneedchat");
assert_eq!(service_of("steam:someone"), "steam");
assert_eq!(service_of("telegram:1234567890"), "telegram", "it was read as IRC, the service with no prefix");
// IRC is the one without a prefix, and a host with a port in it must
// not be read as one.
assert_eq!(service_of("[email protected]"), "irc");
@@ -1032,6 +1044,7 @@ mod tests {
favourite: false,
low_priority: false,
service_room: false,
name_icon: None,
topic: None,
read_only: None,
forum: false,
+307 -2
View File
@@ -112,6 +112,7 @@ fn adding_service(method: &str) -> Option<&'static str> {
"addKickAccount" => Some("kick"),
"addSteamAccount" | "addSteamAccountQr" => Some("steam"),
"addTwitchAccount" | "startTwitchLogin" => Some("twitch"),
"addTelegramAccount" => Some("telegram"),
_ => None,
}
}
@@ -426,6 +427,7 @@ 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,
),
@@ -471,7 +473,13 @@ pub async fn dispatch(
// connect: a few hundred channels would mean a few
// hundred requests, most for conversations nobody
// is about to read.
backend::discord::catch_up_channel(state, &cfg.token, &cfg.user_id, cfg.display_name.as_deref(), buffer_id, &channel_id).await;
// A conversation never read here is read for the first time now; one that has been is asked
// only about what came after.
if state.store.has_messages(buffer_id).unwrap_or(true) {
backend::discord::catch_up_channel(state, &cfg.token, &cfg.user_id, cfg.display_name.as_deref(), buffer_id, &channel_id).await;
} else {
backend::discord::backfill_channel_history(state, &cfg.token, &cfg.user_id, cfg.display_name.as_deref(), buffer_id, &channel_id).await;
}
}
}
// Steam keeps every conversation: opening one asks for its
@@ -490,6 +498,14 @@ 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
@@ -746,6 +762,10 @@ pub async fn dispatch(
// name later.
state.highlights.forget(id);
state.ignores.forget(id);
// And the Discord session kept for a restart to resume: a removed account must not leave its
// servers and friends in a file.
backend::discord::session_store::forget(id);
backend::discord::send_guard::forget_account(id);
// And everything else this process is holding about it: the
// seventy-odd maps keyed by the account or by one of its
// buffers. In memory only, so it mattered exactly when
@@ -781,8 +801,14 @@ 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() {
@@ -1012,6 +1038,12 @@ 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")));
@@ -1080,6 +1112,15 @@ 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.
@@ -1144,6 +1185,27 @@ 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()));
@@ -1232,6 +1294,7 @@ 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 => {
@@ -1257,6 +1320,12 @@ 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()
};
@@ -1307,6 +1376,13 @@ 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:") => {
@@ -1356,7 +1432,8 @@ pub async fn dispatch(
Err(e) => (None, Some(format!("{e:#}"))),
}
} else {
match backend::discord::accept_member_verification(state, account_id, guild_id).await {
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 {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
@@ -1461,6 +1538,8 @@ 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,
@@ -1820,6 +1899,15 @@ 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)
@@ -2197,6 +2285,29 @@ pub async fn dispatch(
Err(e) => tracing::debug!("discord: listing commands: {e:#}"),
}
}
// A Telegram bot's own menu, in a conversation with that bot: what its profile lists, as Telegram's apps
// offer it beside the message box and while a slash is typed.
if service == "telegram" {
// In a conversation with a bot only the bot's own commands are offered: this client's built-ins are
// things said to a chat, and a bot is not asked to /shrug.
if buffer.name_icon.as_deref() == Some("smart_toy") {
out.clear();
}
match backend::telegram::bots::commands(state, &buffer).await {
Ok(list) => {
for c in list.into_iter().filter(|c| query.is_empty() || crate::commands::score(query, &c.name).is_some()) {
out.push(serde_json::json!({
"name": c.name,
"usage": "",
"description": c.description,
"source": buffer.name,
"kind": "bot",
}));
}
}
Err(e) => tracing::debug!("telegram: listing a bot's commands: {e}"),
}
}
(Some(serde_json::json!({ "commands": out })), None)
}
@@ -2300,6 +2411,8 @@ 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"))
};
@@ -2340,6 +2453,8 @@ 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"))
};
@@ -2365,6 +2480,8 @@ 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"))
};
@@ -2414,6 +2531,8 @@ 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"))
};
@@ -2423,6 +2542,25 @@ 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.
//
@@ -3222,6 +3360,20 @@ 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:") && backend::telegram::topics::topic_of_buffer(&buffer).is_some() {
// Closing one topic of a group only stops showing it: the group is left from its rail entry.
} else if buffer.account_id.starts_with("telegram:") {
if let Err(e) = backend::telegram::membership::leave(state, &buffer).await {
return (None, Some(e));
}
// A group's own entry on the rail goes with it.
if let Some(remote) = buffer.remote_id.as_deref().and_then(|r| r.split(':').next()) {
let rail = backend::telegram::peers::rail_id(&buffer.account_id, remote);
if buffer.group_id.as_deref() == Some(rail.as_str()) {
state.runtime.remove_buffer_group(state, &rail);
return (Some(ok_node()), None);
}
}
} 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 {
@@ -3304,6 +3456,14 @@ pub async fn dispatch(
backend::discord::leave_guild(state, account_id, guild_id).await
} else if let Some(room_id) = rest.strip_prefix("space:") {
backend::matrix::leave_room(state, account_id, room_id).await
} else if rest.starts_with("tggroup:") {
// A Telegram group is its rail entry and the one conversation under it: leaving is leaving that.
let under: Vec<crate::model::Buffer> = state.runtime.list_buffers().into_iter().filter(|b| b.group_id.as_deref() == Some(group_id)).collect();
// One leave covers the group, however many topics it has.
match under.first() {
Some(buffer) => backend::telegram::membership::leave(state, buffer).await.map_err(anyhow::Error::msg),
None => Ok(()),
}
} else {
return (None, Some("that is not something you can leave".to_string()));
};
@@ -3414,6 +3574,30 @@ 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
@@ -3610,6 +3794,10 @@ 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:#}"))),
@@ -3650,6 +3838,10 @@ 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)),
@@ -3676,6 +3868,14 @@ 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)),
@@ -4419,6 +4619,39 @@ 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") {
@@ -4951,6 +5184,12 @@ 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:#}"))),
@@ -5001,6 +5240,15 @@ 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 {
@@ -5533,6 +5781,63 @@ 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.
// The GIFs a Telegram account can send: its saved ones, or what Telegram's GIF search finds; and sending one.
"listTelegramGifs" | "sendTelegramGif" => {
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 == "listTelegramGifs" {
return match backend::telegram::gifs::list(state, &buffer, p_str(params, "query", "")).await {
Ok(list) => (Some(list), None),
Err(e) => (None, Some(e)),
};
}
match backend::telegram::gifs::send(state, &buffer, p_str(params, "id", "")).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e)),
}
}
// Lets a Discord connection that is being held for having logged in too often through now.
"connectDiscordAnyway" => {
let Some(account_id) = p_str_opt(params, "accountId").filter(|a| a.starts_with("discord:")) else {
return (None, Some("connectDiscordAnyway requires a Discord \"accountId\"".to_string()));
};
if backend::discord::identify_budget::connect_anyway(account_id) {
(Some(ok_node()), None)
} else {
(None, Some("That account is not being held".to_string()))
}
}
// Pressing a button under a bot's message; answers what the bot said back, for the window to show.
"pressTelegramButton" => {
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("pressTelegramButton requires a Telegram \"bufferId\"".to_string()));
};
match backend::telegram::keyboards::press(state, &buffer, p_str(params, "messageId", ""), p_str(params, "customId", "")).await {
Ok(answer) => (Some(answer), None),
Err(e) => (None, Some(e)),
}
}
// 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),
+73 -2
View File
@@ -543,6 +543,8 @@ 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.
@@ -1058,6 +1060,10 @@ 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() })
}
@@ -1106,6 +1112,7 @@ 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()),
@@ -1256,6 +1263,11 @@ 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
}
@@ -1532,6 +1544,24 @@ impl Runtime {
}
}
/// Says a conversation was active at `ts`, if that is later than what is known: for a service that tells when its
/// last message was without sending it.
pub fn raise_buffer_activity(&self, state: &AppState, buffer_id: &str, ts: i64) {
let updated = {
let mut buffers = self.buffers.lock().unwrap();
match buffers.get_mut(buffer_id) {
Some(b) if b.last_activity_ts < ts => {
b.last_activity_ts = ts;
Some(b.clone())
}
_ => None,
}
};
if let Some(b) = updated {
state.events.emit("bufferListChange", serde_json::to_value(&b).unwrap());
}
}
pub fn is_server_buffer(&self, buffer_id: &str) -> bool {
self.buffers
.lock()
@@ -1658,7 +1688,9 @@ impl Runtime {
if let Some(existing) = buffers.get_mut(&id) {
// A Twitch account's list is always headed the same two ways (see `fixed_category`), including
// for what was open before it was.
if existing.category.is_none() {
// Only in the account's own list: a conversation filed under a rail entry of its own (a Telegram group)
// is the whole of that entry and has no heading to sit under.
if existing.category.is_none() && existing.group_id.as_deref().is_none_or(|g| g == model::account_group_id(account_id)) {
existing.category = fixed_category(account_id, kind);
}
return existing.clone();
@@ -1693,6 +1725,7 @@ impl Runtime {
favourite: false,
low_priority: false,
service_room: false,
name_icon: None,
topic: None,
read_only: None,
forum: false,
@@ -1759,6 +1792,23 @@ 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();
@@ -4345,6 +4395,18 @@ 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);
}
@@ -4830,6 +4892,15 @@ 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
@@ -5619,7 +5690,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, steam_requests, kick_channels,
sneedchat_senders, kick_senders, twitch_senders, twitch_rights, steam_links, telegram_handles, 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,
+140 -12
View File
@@ -193,6 +193,8 @@ 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, []);
}
@@ -449,8 +451,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)
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)",
"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)",
params![
msg_id,
buffer_id,
@@ -476,6 +478,7 @@ 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
@@ -519,6 +522,27 @@ impl Store {
Ok(changed > 0)
}
/// Puts a sender's badges on a message already stored without them, without marking it changed.
pub fn set_badges(&self, buffer_id: &str, msg_id: &str, badges: &[crate::backend::kick::api::Badge]) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let json = serde_json::to_string(badges)?;
let changed = conn.execute(
"UPDATE messages SET badges = ?1 WHERE buffer_id = ?2 AND msg_id = ?3 AND (badges IS NULL OR badges = '[]')",
params![json, buffer_id, msg_id],
)?;
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> {
@@ -753,6 +777,44 @@ impl Store {
Ok(id)
}
/// The most recent message this buffer holds, and whether it is the account's own.
pub fn newest_msg(&self, buffer_id: &str) -> Result<Option<(String, bool)>> {
let conn = self.conn.lock().unwrap();
let row = conn
.query_row(
"SELECT msg_id, is_own FROM messages WHERE buffer_id = ?1 AND msg_id IS NOT NULL ORDER BY ts DESC LIMIT 1",
params![buffer_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? != 0)),
)
.optional()?;
Ok(row)
}
/// The ids of the account's own messages in these buffers from `since` (Unix seconds) on, newest first, at most
/// `limit`.
pub fn own_message_ids_since(&self, buffer_ids: &[String], since: i64, limit: usize) -> Result<Vec<String>> {
let mut found = Vec::new();
if buffer_ids.is_empty() {
return Ok(found);
}
let conn = self.conn.lock().unwrap();
for chunk in buffer_ids.chunks(200) {
let marks = vec!["?"; chunk.len()].join(",");
let sql = format!("SELECT msg_id FROM messages WHERE is_own = 1 AND ts >= ? AND msg_id IS NOT NULL AND buffer_id IN ({marks}) ORDER BY ts DESC LIMIT {limit}");
let mut args: Vec<&dyn rusqlite::ToSql> = vec![&since];
args.extend(chunk.iter().map(|b| b as &dyn rusqlite::ToSql));
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(args.as_slice(), |row| row.get::<_, String>(0))?;
for id in rows {
found.push(id?);
}
if found.len() >= limit {
break;
}
}
Ok(found)
}
/// The most recent message this buffer holds, for asking a service what
/// has happened since.
pub fn newest_msg_id(&self, buffer_id: &str) -> Result<Option<String>> {
@@ -780,7 +842,8 @@ 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() });
thread_root: row.get::<_, Option<String>>("reply_thread_root").ok().flatten(),
chat: row.get::<_, Option<String>>("reply_chat").ok().flatten() });
let json_column = |name: &str| -> String {
row.get::<_, Option<String>>(name).ok().flatten().unwrap_or_else(|| "[]".to_string())
};
@@ -823,7 +886,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, 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, reply_chat, moderated
FROM messages
WHERE buffer_id = ?1 AND (?2 <= 0 OR ts < ?2)
ORDER BY ts DESC LIMIT ?3",
@@ -854,6 +917,13 @@ impl Store {
Ok(())
}
/// Takes the buttons off a message, for an edit that removed them.
pub fn clear_components(&self, buffer_id: &str, msg_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("UPDATE messages SET components = '[]' WHERE buffer_id = ?1 AND msg_id = ?2", params![buffer_id, msg_id])?;
Ok(())
}
/// The names that have spoken here lately, most recent first.
///
/// Wanted because a roster is not always there to ask: a service may not
@@ -899,7 +969,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, 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, reply_chat, moderated
FROM messages
WHERE buffer_id = ?1 AND ts > ?2
ORDER BY ts ASC LIMIT ?3",
@@ -922,7 +992,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, 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, reply_chat, moderated
FROM messages
WHERE buffer_id = ?1 AND ts > ?2 AND ts <= ?3
ORDER BY ts ASC LIMIT ?4",
@@ -963,7 +1033,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, 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, reply_chat, moderated";
let mut older = conn.prepare(&format!(
"SELECT {COLUMNS} FROM messages WHERE buffer_id = ?1 AND ts <= ?2 ORDER BY ts DESC LIMIT ?3"
@@ -1009,7 +1079,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, 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, reply_chat, moderated, buffer_id
FROM messages
WHERE is_highlight = 1 AND is_own = 0 AND (buffer_kind = 'channel'{legacy})
ORDER BY ts DESC LIMIT ?"
@@ -1270,7 +1340,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, 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, reply_chat, moderated
FROM messages
WHERE buffer_id = ?1 AND body LIKE ?2 ESCAPE '\\'
ORDER BY ts DESC LIMIT ?3",
@@ -1291,7 +1361,13 @@ 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 };
!std::path::Path::new(rest).exists()
// 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()
};
for att in attachments.iter_mut() {
if gone(&att.thumbnail_path) {
@@ -1335,7 +1411,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, 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, reply_chat, 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",
@@ -1347,7 +1423,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, 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, reply_chat, moderated
FROM messages
WHERE buffer_id = ?1 AND msg_id = ?2",
)?;
@@ -1815,6 +1891,42 @@ mod tests {
.expect("appending");
}
/// Like `append_at`, but the account's own.
fn append_own_at(s: &Store, buffer: &str, id: &str, ts: i64) {
s.append_message(buffer, id, "me", "hi", ts, false, false, "chat", None, &[], true, None, &[], &[], None, None, "channel", None, &[])
.expect("appending");
}
#[test]
fn finds_the_newest_message_and_whether_it_is_the_accounts_own() {
let (s, _dir) = store();
assert_eq!(s.newest_msg("b").unwrap(), None);
append_at(&s, "b", "m1", 100);
assert_eq!(s.newest_msg("b").unwrap(), Some(("m1".to_string(), false)));
append_own_at(&s, "b", "m2", 200);
assert_eq!(s.newest_msg("b").unwrap(), Some(("m2".to_string(), true)));
append_at(&s, "b", "m3", 300);
assert_eq!(s.newest_msg("b").unwrap(), Some(("m3".to_string(), false)));
}
#[test]
fn finds_what_the_account_has_said_in_a_set_of_rooms_since_a_time() {
let (s, _dir) = store();
let rooms = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
assert!(s.own_message_ids_since(&[], 0, 5).unwrap().is_empty(), "no rooms is no message");
append_at(&s, "a", "m1", 500);
assert!(s.own_message_ids_since(&rooms(&["a"]), 0, 5).unwrap().is_empty(), "somebody else's message is not the account's");
append_own_at(&s, "b", "m2", 100);
append_own_at(&s, "b", "m3", 200);
assert_eq!(s.own_message_ids_since(&rooms(&["a", "b"]), 0, 5).unwrap(), vec!["m3", "m2"], "newest first");
assert!(s.own_message_ids_since(&rooms(&["a"]), 0, 5).unwrap().is_empty(), "in another room");
assert!(s.own_message_ids_since(&rooms(&["b"]), 201, 5).unwrap().is_empty(), "before the account joined");
assert_eq!(s.own_message_ids_since(&rooms(&["b"]), 0, 1).unwrap(), vec!["m3"], "no more than asked for");
// More rooms than one query holds.
let many: Vec<String> = (0..450).map(|i| format!("r{i}")).chain(std::iter::once("b".to_string())).collect();
assert_eq!(s.own_message_ids_since(&many, 0, 5).unwrap().len(), 2);
}
#[test]
fn finds_the_newest_message_to_mark_a_room_read_up_to() {
let (s, _dir) = store();
@@ -2000,6 +2112,22 @@ 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